Compare commits
111 Commits
0a49c5719c
...
issue-24-u
| Author | SHA1 | Date | |
|---|---|---|---|
| 01c4ccb316 | |||
| 00d4f2a70b | |||
| f8215a0c9a | |||
| ea9e3e41e3 | |||
| 5b5a5bc637 | |||
| c2e5a534aa | |||
| d0c23deb72 | |||
| 4c8ed24561 | |||
| 404b7f7500 | |||
| beca073ddc | |||
| 362a9dde62 | |||
| a73d34ac9f | |||
| 1c8cbd96ca | |||
| 86873bd035 | |||
| 2642f14e49 | |||
| bb62382e18 | |||
| c5865a5379 | |||
| 58d567f9bc | |||
| 2d9f453767 | |||
| 20a9f93378 | |||
| b78f102e9d | |||
| 8858e049ee | |||
| a75b97c4c0 | |||
| b4fc5a14b2 | |||
| a0fd33fcb8 | |||
| ef1761385e | |||
| 17215fd191 | |||
| 97753c3d3c | |||
| 994f7f66c4 | |||
| f76b6cad14 | |||
| 90e293facd | |||
| 50eb7cf2f3 | |||
| 26265be440 | |||
| 5faef1492d | |||
| 3045093dcf | |||
| 64a0ff8322 | |||
| 155ff63a73 | |||
| a9e40df44b | |||
| 98695f9888 | |||
| d8c0ee5d1e | |||
| 851f967f12 | |||
| c97c595253 | |||
| f5acdf2fff | |||
| e6ed7954de | |||
| 73e6bb7285 | |||
| d451d01fe2 | |||
| bfe17969e6 | |||
| 7cd87bc8a8 | |||
| 0babc57f57 | |||
| f41e59e695 | |||
| f3dcb8a3b9 | |||
| c2d7342a47 | |||
| 5ca2733ea9 | |||
| 8bff241b12 | |||
| 1e04da9de8 | |||
| 39b6cf3469 | |||
| 124421b85c | |||
| 86eee0f916 | |||
| 9624fb469f | |||
| e8169eae42 | |||
| fc22842fc4 | |||
| 6940dc55b7 | |||
| 728e5c740a | |||
| d52a9167d1 | |||
| d67ba9cada | |||
| bbb0f652f3 | |||
| 088f43279e | |||
| 7e5d31d53b | |||
| 8bd2a8ff1a | |||
| d3ffca1843 | |||
| 25d2a5b1a7 | |||
| df3dcc47c9 | |||
| ccacad3d37 | |||
| 90f28410ce | |||
| bb7907846e | |||
| 96b91b2a69 | |||
| 98d152da06 | |||
| 97797c574d | |||
| c7cfb603b0 | |||
| 38f946d1c4 | |||
| 44d821c638 | |||
| b1789d8621 | |||
| 372a857f15 | |||
| b93e19a7c8 | |||
| 1297a839d4 | |||
| c018011e83 | |||
| e65537c6de | |||
| 91ce7ad4c5 | |||
| 4fb33eca6c | |||
| 83334803b9 | |||
| 5c469f723a | |||
| 1d90454109 | |||
| 573177e118 | |||
| 3da877eb8a | |||
| fe8e65e12e | |||
| aea22597ba | |||
| 66e1f549b9 | |||
| 6bcc15c174 | |||
| 4dbde43465 | |||
| 6d7bf531ac | |||
| 123583a502 | |||
| 3f3bdf2d45 | |||
| b0c6afad09 | |||
| 2778a589ea | |||
| ccd89119da | |||
| d158644567 | |||
| e16c13a1e7 | |||
| 797619acd9 | |||
| 254f915051 | |||
| 659377d894 | |||
| 5808800981 |
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync
|
||||||
|
POBSYNC_DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backup.example.com
|
||||||
|
POBSYNC_DJANGO_SECRET_KEY=change-me-to-a-long-random-secret
|
||||||
|
POBSYNC_DJANGO_DEBUG=0
|
||||||
|
POBSYNC_WEB_BIND=127.0.0.1
|
||||||
|
POBSYNC_GUNICORN_WORKERS=2
|
||||||
|
POBSYNC_GUNICORN_TIMEOUT=120
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,8 +2,10 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
var/
|
var/
|
||||||
|
backups/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
.env
|
||||||
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0 - 2026-05-21
|
||||||
|
|
||||||
|
Initial stable release of the Django-first pobsync control panel.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Django control panel for hosts, global settings, schedules, SSH credentials, snapshots, runs, self-checks, and logs.
|
||||||
|
- Native systemd installer and updater for production backup servers.
|
||||||
|
- SQLite by default, with optional MariaDB support.
|
||||||
|
- Scheduler and worker services for queued manual backups and scheduled backups.
|
||||||
|
- Manual backup, dry-run, cancellation, verbose rsync logging, and run detail views.
|
||||||
|
- Snapshot discovery for existing backup directories and SQL-backed snapshot records.
|
||||||
|
- SQL retention planning and apply flow with base snapshot protection and incomplete snapshot visibility.
|
||||||
|
- Explicit cleanup flow for incomplete snapshots, separate from normal retention pruning.
|
||||||
|
- Purged snapshot audit overview with reason, action source, operator, host, kind, path, and timestamp.
|
||||||
|
- Dashboard and host pages with backup health, latest run/snapshot, next run, and storage/stat summaries.
|
||||||
|
- Review resolution for failed/warning runs and incomplete snapshot tasks so operational warnings can be acknowledged.
|
||||||
|
- Worker heartbeat metadata and stale running-run reconciliation for queued backup workers.
|
||||||
|
- SSH key generation, upload, edit, guarded delete, known_hosts management, and per-host key selection.
|
||||||
|
- In-app changelog page sourced from this changelog.
|
||||||
|
- Restore guidance on snapshot detail pages.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Django and the database are now the source of truth for configuration.
|
||||||
|
- Docker Compose is documented as development and disposable test tooling rather than the primary production path.
|
||||||
|
- The `pobsync` console entrypoint is now a maintainer layer around Django management commands.
|
||||||
|
- Scheduled pruning is evaluated by the pobsync scheduler service and recorded through Django, not host cron.
|
||||||
|
- Retention and incomplete cleanup now preserve audit history even after source snapshot records are removed.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Legacy YAML config import/export workflow.
|
||||||
|
- Public short aliases for configuration commands.
|
||||||
|
- Obsolete global config storage fields.
|
||||||
@@ -10,7 +10,7 @@ RUN apt-get update \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md CHANGELOG.md ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY manage.py ./
|
COPY manage.py ./
|
||||||
COPY scripts/docker-entrypoint ./scripts/docker-entrypoint
|
COPY scripts/docker-entrypoint ./scripts/docker-entrypoint
|
||||||
@@ -24,4 +24,4 @@ RUN chmod +x ./scripts/docker-entrypoint
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["./scripts/docker-entrypoint"]
|
ENTRYPOINT ["./scripts/docker-entrypoint"]
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["gunicorn", "pobsync_server.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"]
|
||||||
|
|||||||
273
README.md
273
README.md
@@ -1,155 +1,272 @@
|
|||||||
# pobsync
|
# pobsync
|
||||||
|
|
||||||
`pobsync` is a pull-based backup service. It runs on a central backup server and pulls data from remote machines via rsync over SSH.
|
`pobsync` is a pull-based backup service. It runs on a central backup server and pulls data from remote machines via
|
||||||
|
rsync over SSH.
|
||||||
|
|
||||||
The refactor direction is SQL-first:
|
The current refactor is Django-first and SQL-backed:
|
||||||
|
|
||||||
- Django is the management layer and source of truth.
|
- The Django control panel is the primary interface for setup and operations.
|
||||||
|
- The database is the source of truth for hosts, schedules, runs, snapshots, credentials, and retention settings.
|
||||||
- SQLite is the default database; MariaDB is optional.
|
- SQLite is the default database; MariaDB is optional.
|
||||||
- Backups still use the existing rsync snapshot engine internally.
|
- Backups use the existing rsync snapshot engine internally.
|
||||||
- Scheduling is handled by a Django/Docker scheduler process, not host cron.
|
- Scheduling is handled by a Django scheduler service, not host cron.
|
||||||
- Legacy YAML import/export exists only for migration and inspection.
|
- SSH keys can be managed from Django and selected globally or per host.
|
||||||
|
|
||||||
## Requirements
|
## Recommended Production Install
|
||||||
|
|
||||||
On the backup server or in the container:
|
The recommended production deployment is native systemd services on the backup server. Docker Compose remains available
|
||||||
|
for development and disposable test installs, but native systemd avoids Docker friction around SSH, filesystem mounts,
|
||||||
|
large backup storage, and host-level service logs.
|
||||||
|
|
||||||
- Python 3.11+
|
Recommended layout:
|
||||||
- rsync
|
|
||||||
- ssh
|
|
||||||
- SSH key-based access from the backup server to remotes
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 -m venv .venv
|
/opt/pobsync/app # installed app checkout
|
||||||
. .venv/bin/activate
|
/opt/pobsync/venv # Python virtualenv
|
||||||
python3 -m pip install -e .
|
/etc/pobsync/pobsync.env # settings and secrets
|
||||||
mkdir -p var
|
/var/lib/pobsync # SQLite database, state, runtime SSH key files, static files
|
||||||
python3 manage.py migrate
|
/backups # backup storage, or set another absolute path
|
||||||
python3 manage.py createsuperuser
|
|
||||||
python3 manage.py runserver
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The admin is available at:
|
From a checked-out copy of this repository, run:
|
||||||
|
|
||||||
- http://127.0.0.1:8000/admin/
|
|
||||||
|
|
||||||
## SQL-First Setup
|
|
||||||
|
|
||||||
Create global config:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync configure-global --backup-root /mnt/backups/pobsync
|
sudo scripts/install-systemd
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a host config:
|
When run from a terminal, the installer asks for the important paths and settings with sensible defaults already filled
|
||||||
|
in. It can also create the first Django superuser and prints the next steps when installation is complete.
|
||||||
|
|
||||||
|
The installer will, by default:
|
||||||
|
|
||||||
|
- install required Debian/Ubuntu OS packages with `apt-get`
|
||||||
|
- copy the checkout to `/opt/pobsync/app`
|
||||||
|
- create `/opt/pobsync/venv`
|
||||||
|
- write `/etc/pobsync/pobsync.env` if it does not exist
|
||||||
|
- install `pobsync-manage`, a Django management wrapper that loads `/etc/pobsync/pobsync.env`
|
||||||
|
- create `/var/lib/pobsync`, `/var/log/pobsync`, and the backup root
|
||||||
|
- install Python dependencies
|
||||||
|
- run migrations and collect static files
|
||||||
|
- generate a default SSH key for the service user if one does not exist yet
|
||||||
|
- install and start `pobsync-web`, `pobsync-worker`, and `pobsync-scheduler`
|
||||||
|
- guide you through the first login and setup steps
|
||||||
|
|
||||||
|
Common overrides:
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync configure-host <host> --address <host-or-ip>
|
sudo scripts/install-systemd \
|
||||||
|
--backup-root /mnt/backups/pobsync \
|
||||||
|
--time-zone Europe/Amsterdam \
|
||||||
|
--allowed-hosts backup.example.com,localhost,127.0.0.1 \
|
||||||
|
--csrf-trusted-origins https://backup.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a backup:
|
Use `--no-install-os-packages` if you want to manage system packages yourself. Use `--force-env` only when you want the
|
||||||
|
installer to rewrite an existing `/etc/pobsync/pobsync.env`.
|
||||||
|
Use `--non-interactive` for scripted installs. Use `--verbose` when you want to see the underlying apt, pip, Django, and
|
||||||
|
systemd output.
|
||||||
|
|
||||||
|
Schedules are evaluated in `POBSYNC_TIME_ZONE`. The installer defaults this to the server timezone when it can detect
|
||||||
|
one, otherwise `UTC`; override it with `--time-zone Europe/Amsterdam` or by editing `/etc/pobsync/pobsync.env`.
|
||||||
|
|
||||||
|
For MariaDB support, add:
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync backup <host> --prune
|
sudo scripts/install-systemd --install-extras mariadb
|
||||||
```
|
```
|
||||||
|
|
||||||
Create or update a schedule:
|
## Services
|
||||||
|
|
||||||
|
The installer creates:
|
||||||
|
|
||||||
|
- `pobsync-web.service`: Gunicorn Django control panel on `127.0.0.1:8010`
|
||||||
|
- `pobsync-worker.service`: queued backup worker
|
||||||
|
- `pobsync-scheduler.service`: SQL-backed schedule dispatcher
|
||||||
|
|
||||||
|
Check service state and logs:
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync schedule <host> --cron "15 2 * * *" --prune
|
systemctl status pobsync-web pobsync-worker pobsync-scheduler
|
||||||
|
journalctl -u pobsync-worker -f
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the scheduler:
|
Restart after configuration changes:
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync scheduler --loop --interval 60
|
sudo systemctl restart pobsync-web pobsync-worker pobsync-scheduler
|
||||||
```
|
```
|
||||||
|
|
||||||
Plan or apply retention manually:
|
## Reverse Proxy
|
||||||
|
|
||||||
|
Use an existing reverse proxy by forwarding to:
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync retention <host>
|
http://127.0.0.1:8010
|
||||||
pobsync retention <host> --apply --yes --max-delete 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Discover snapshots already present on disk:
|
To install a starter nginx site file:
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync discover-snapshots --host <host>
|
sudo scripts/install-systemd --with-nginx --server-name backup.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The `pobsync` executable is a thin wrapper around Django management commands. Direct Django access is also available:
|
For HTTPS behind a reverse proxy, set:
|
||||||
|
|
||||||
```
|
```
|
||||||
pobsync django check
|
POBSYNC_DJANGO_ALLOWED_HOSTS=backup.example.com,localhost,127.0.0.1
|
||||||
python3 manage.py run_pobsync_backup <host> --prune
|
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=https://backup.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## Migration Helpers
|
## Django UI
|
||||||
|
|
||||||
Import existing legacy YAML configs:
|
After install, open the control panel through your reverse proxy or directly at:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 manage.py import_pobsync_configs --prefix /opt/pobsync
|
http://127.0.0.1:8010/
|
||||||
```
|
```
|
||||||
|
|
||||||
Export SQL config to legacy runtime YAML for inspection or one-off compatibility:
|
Create a superuser if needed:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 manage.py export_pobsync_configs --prefix /opt/pobsync
|
sudo -u pobsync pobsync-manage createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
These commands are migration helpers, not the normal operating model.
|
For other Django management commands on native installs, use `pobsync-manage` so the production environment file is
|
||||||
|
loaded before Django starts:
|
||||||
## Docker With SQLite
|
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose up --build web
|
sudo -u pobsync pobsync-manage showmigrations pobsync_backend
|
||||||
|
sudo -u pobsync pobsync-manage check
|
||||||
|
sudo -u pobsync pobsync-manage check_pobsync_install
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts Django on:
|
The UI includes:
|
||||||
|
|
||||||
- http://127.0.0.1:8000/admin/
|
- dashboard and host detail pages
|
||||||
|
- global and per-host config forms
|
||||||
|
- schedule editing
|
||||||
|
- manual backup queueing
|
||||||
|
- snapshot discovery
|
||||||
|
- host checks for backup directories and SSH readiness
|
||||||
|
- host directory preparation for new or existing hosts
|
||||||
|
- SQL retention planning and apply flow
|
||||||
|
- Django-managed SSH keys
|
||||||
|
- `/self-check/` for runtime checks
|
||||||
|
- `/logs/` for filtered pobsync service logs
|
||||||
|
|
||||||
Run the scheduler alongside the web admin:
|
## Restoring Data
|
||||||
|
|
||||||
|
pobsync 1.0 treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
|
||||||
|
detail page, but it does not run restore commands for you yet. That is deliberate: restores should be inspected and
|
||||||
|
tested before data is copied back into a live system.
|
||||||
|
|
||||||
|
Each snapshot directory contains:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose up --build web scheduler
|
<snapshot>/data/ # backed-up filesystem contents
|
||||||
|
<snapshot>/meta/ # metadata and rsync logs
|
||||||
```
|
```
|
||||||
|
|
||||||
The container persists `/opt/pobsync` and the SQLite database in Docker volumes.
|
Use the `data/` directory as the rsync source. Start with a dry run and restore to a staging path first:
|
||||||
|
|
||||||
## Docker With MariaDB
|
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose --profile mariadb up --build web-mariadb
|
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/ /restore/example.org/
|
||||||
|
rsync -aHAX --numeric-ids --info=progress2 /backups/example.org/scheduled/<snapshot>/data/ /restore/example.org/
|
||||||
```
|
```
|
||||||
|
|
||||||
With the scheduler:
|
After validating the staged files, copy the specific files or directories back to the target machine. For a full-host
|
||||||
|
restore, use another dry run before writing to the remote root:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose --profile mariadb up --build web-mariadb scheduler-mariadb
|
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/ root@example.org:/
|
||||||
```
|
```
|
||||||
|
|
||||||
SQLite remains the default because it is enough for a single backup server and keeps deployment simple.
|
For most incidents, prefer a targeted restore instead of copying the whole snapshot. Keep paths relative to the
|
||||||
|
snapshot's `data/` directory:
|
||||||
|
|
||||||
## Current Architecture
|
```
|
||||||
|
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/etc/nginx/ /restore/example.org/etc/nginx/
|
||||||
|
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/home/example/site/public_html/index.php /restore/example.org/home/example/site/public_html/index.php
|
||||||
|
```
|
||||||
|
|
||||||
The public command surface is Django-first. The old YAML/cron CLI has been retired from the `pobsync` entrypoint.
|
Snapshots may use hardlinks for files that are unchanged between backups. That saves disk space and is safe for normal
|
||||||
|
restore copies, but do not edit files inside snapshot directories. Treat snapshots as read-only and copy data out with
|
||||||
|
rsync.
|
||||||
|
|
||||||
The remaining internal engine code still contains reusable backup primitives:
|
## SSH Keys
|
||||||
|
|
||||||
- snapshot naming and metadata
|
SSH keys can be managed from `/ssh-credentials/`. The recommended flow is to generate keys from Django or during the
|
||||||
- rsync command construction and execution
|
installer. pobsync stores the private key on disk under the runtime state root (`POBSYNC_HOME`), keeps the public key
|
||||||
- retention planning and pruning
|
visible in the UI, and lets you select a credential either as the global default or as a per-host override.
|
||||||
- host locking
|
|
||||||
|
|
||||||
Next refactor targets:
|
Generated private keys are stored at:
|
||||||
|
|
||||||
- Record discovered snapshots into `SnapshotRecord`.
|
```
|
||||||
- Surface `SnapshotRecord` data through API/admin views instead of filesystem inspection.
|
$POBSYNC_HOME/state/ssh-credentials/<id>/identity
|
||||||
- Move more snapshot lifecycle details into typed domain objects.
|
```
|
||||||
- Replace remaining dictionary-shaped config at engine boundaries.
|
|
||||||
- Remove legacy YAML import/export once production migration no longer needs it.
|
The key file is written with `0600` permissions and injected into the rsync SSH command with `IdentityFile`. Copy the
|
||||||
|
public key shown in Django to the target host's `authorized_keys`.
|
||||||
|
|
||||||
|
Existing private keys can still be added manually, but generated filesystem keys are preferred for native systemd
|
||||||
|
production installs.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
From a fresh checkout or the existing app directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
git pull
|
||||||
|
sudo scripts/update-systemd
|
||||||
|
```
|
||||||
|
|
||||||
|
The updater is a thin wrapper around the installer for normal production deploys. It preserves the existing
|
||||||
|
`/etc/pobsync/pobsync.env`, skips OS package installation, skips superuser creation, refreshes the installed app, updates
|
||||||
|
Python dependencies, runs migrations, collects static files, and restarts the systemd services so new Django code is
|
||||||
|
loaded.
|
||||||
|
|
||||||
|
Use the full installer again when you intentionally want to change install-time settings, install OS packages, enable
|
||||||
|
nginx, or rewrite the environment file:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo scripts/install-systemd --non-interactive
|
||||||
|
sudo scripts/install-systemd --force-env
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check:
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl status pobsync-web pobsync-worker pobsync-scheduler
|
||||||
|
sudo -u pobsync pobsync-manage check
|
||||||
|
sudo -u pobsync pobsync-manage check_pobsync_install
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart services manually after environment or reverse proxy changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo systemctl restart pobsync-web pobsync-worker pobsync-scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect service logs with:
|
||||||
|
|
||||||
|
```
|
||||||
|
journalctl -u pobsync-web -n 100 --no-pager
|
||||||
|
journalctl -u pobsync-worker -f
|
||||||
|
journalctl -u pobsync-scheduler -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback to a previous revision by checking out the known-good commit or tag, then running the updater again:
|
||||||
|
|
||||||
|
```
|
||||||
|
git switch master
|
||||||
|
git pull
|
||||||
|
git checkout <known-good-commit-or-tag>
|
||||||
|
sudo scripts/update-systemd
|
||||||
|
sudo -u pobsync pobsync-manage check_pobsync_install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Development, Docker, maintainer tooling, and architecture notes live in:
|
||||||
|
|
||||||
|
- [docs/development.md](docs/development.md)
|
||||||
|
|||||||
24
deploy/bin/pobsync-manage
Normal file
24
deploy/bin/pobsync-manage
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_DIR="@POBSYNC_APP_DIR@"
|
||||||
|
VENV_DIR="@POBSYNC_VENV_DIR@"
|
||||||
|
ENV_FILE="@POBSYNC_ENV_FILE@"
|
||||||
|
SERVICE_USER="@POBSYNC_USER@"
|
||||||
|
SERVICE_GROUP="@POBSYNC_GROUP@"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "pobsync environment file not found: $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
export POBSYNC_ENV_FILE="$ENV_FILE"
|
||||||
|
export POBSYNC_SERVICE_USER="$SERVICE_USER"
|
||||||
|
export POBSYNC_SERVICE_GROUP="$SERVICE_GROUP"
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
exec "$VENV_DIR/bin/python" "$APP_DIR/manage.py" "$@"
|
||||||
15
deploy/nginx/pobsync.conf
Normal file
15
deploy/nginx/pobsync.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name @POBSYNC_SERVER_NAME@;
|
||||||
|
|
||||||
|
client_max_body_size 16m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8010;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
deploy/pobsync.env.example
Normal file
18
deploy/pobsync.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
POBSYNC_DJANGO_DEBUG=0
|
||||||
|
POBSYNC_DJANGO_SECRET_KEY=change-me-to-a-long-random-secret
|
||||||
|
POBSYNC_DJANGO_ALLOWED_HOSTS=backup.example.com,localhost,127.0.0.1
|
||||||
|
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=https://backup.example.com
|
||||||
|
|
||||||
|
POBSYNC_HOME=/var/lib/pobsync
|
||||||
|
POBSYNC_BACKUP_ROOT=/backups
|
||||||
|
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
|
||||||
|
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static
|
||||||
|
POBSYNC_ENV_FILE=/etc/pobsync/pobsync.env
|
||||||
|
POBSYNC_SERVICE_USER=pobsync
|
||||||
|
POBSYNC_SERVICE_GROUP=pobsync
|
||||||
|
|
||||||
|
POBSYNC_WEB_BIND=127.0.0.1:8010
|
||||||
|
POBSYNC_GUNICORN_WORKERS=2
|
||||||
|
POBSYNC_GUNICORN_TIMEOUT=120
|
||||||
|
POBSYNC_WORKER_INTERVAL=15
|
||||||
|
POBSYNC_SCHEDULER_INTERVAL=60
|
||||||
20
deploy/systemd/pobsync-scheduler.service
Normal file
20
deploy/systemd/pobsync-scheduler.service
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=pobsync schedule dispatcher
|
||||||
|
After=network-online.target pobsync-web.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=@POBSYNC_USER@
|
||||||
|
Group=@POBSYNC_GROUP@
|
||||||
|
WorkingDirectory=@POBSYNC_APP_DIR@
|
||||||
|
EnvironmentFile=@POBSYNC_ENV_FILE@
|
||||||
|
Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@
|
||||||
|
Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@
|
||||||
|
Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@
|
||||||
|
ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_scheduler --loop --interval "${POBSYNC_SCHEDULER_INTERVAL:-60}"'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
22
deploy/systemd/pobsync-web.service
Normal file
22
deploy/systemd/pobsync-web.service
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=pobsync Django control panel
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=@POBSYNC_USER@
|
||||||
|
Group=@POBSYNC_GROUP@
|
||||||
|
WorkingDirectory=@POBSYNC_APP_DIR@
|
||||||
|
EnvironmentFile=@POBSYNC_ENV_FILE@
|
||||||
|
Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@
|
||||||
|
Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@
|
||||||
|
Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@
|
||||||
|
ExecStartPre=@POBSYNC_VENV_DIR@/bin/python manage.py migrate --noinput
|
||||||
|
ExecStartPre=@POBSYNC_VENV_DIR@/bin/python manage.py collectstatic --noinput --clear
|
||||||
|
ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/gunicorn pobsync_server.wsgi:application --bind "${POBSYNC_WEB_BIND:-127.0.0.1:8010}" --workers "${POBSYNC_GUNICORN_WORKERS:-2}" --timeout "${POBSYNC_GUNICORN_TIMEOUT:-120}"'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
20
deploy/systemd/pobsync-worker.service
Normal file
20
deploy/systemd/pobsync-worker.service
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=pobsync queued backup worker
|
||||||
|
After=network-online.target pobsync-web.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=@POBSYNC_USER@
|
||||||
|
Group=@POBSYNC_GROUP@
|
||||||
|
WorkingDirectory=@POBSYNC_APP_DIR@
|
||||||
|
EnvironmentFile=@POBSYNC_ENV_FILE@
|
||||||
|
Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@
|
||||||
|
Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@
|
||||||
|
Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@
|
||||||
|
ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_worker --loop --interval "${POBSYNC_WORKER_INTERVAL:-15}"'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,40 +1,75 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
command: python manage.py runserver 0.0.0.0:8000
|
command: gunicorn pobsync_server.wsgi:application --bind 0.0.0.0:8000 --workers ${POBSYNC_GUNICORN_WORKERS:-2} --timeout ${POBSYNC_GUNICORN_TIMEOUT:-120}
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POBSYNC_DJANGO_DEBUG: "1"
|
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
|
||||||
POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me"
|
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
|
||||||
POBSYNC_DJANGO_ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0"
|
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
|
||||||
POBSYNC_HOME: "/opt/pobsync"
|
POBSYNC_HOME: "/opt/pobsync"
|
||||||
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
|
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${POBSYNC_WEB_BIND:-0.0.0.0}:8010:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- pobsync_state:/opt/pobsync
|
||||||
- pobsync_db:/var/lib/pobsync
|
- pobsync_db:/var/lib/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
build: .
|
build: .
|
||||||
command: python manage.py run_pobsync_scheduler --loop --interval 60
|
command: python manage.py run_pobsync_scheduler --loop --interval 60
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POBSYNC_DJANGO_DEBUG: "1"
|
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
|
||||||
POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me"
|
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
|
||||||
POBSYNC_DJANGO_ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0"
|
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
|
||||||
POBSYNC_HOME: "/opt/pobsync"
|
POBSYNC_HOME: "/opt/pobsync"
|
||||||
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
|
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
|
||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- pobsync_state:/opt/pobsync
|
||||||
- pobsync_db:/var/lib/pobsync
|
- pobsync_db:/var/lib/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: .
|
||||||
|
command: python manage.py run_pobsync_worker --loop --interval 15
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
|
||||||
|
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
|
||||||
|
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
|
||||||
|
POBSYNC_HOME: "/opt/pobsync"
|
||||||
|
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
|
||||||
|
volumes:
|
||||||
|
- pobsync_state:/opt/pobsync
|
||||||
|
- pobsync_db:/var/lib/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
web-mariadb:
|
web-mariadb:
|
||||||
profiles: ["mariadb"]
|
profiles: ["mariadb"]
|
||||||
build: .
|
build: .
|
||||||
command: python manage.py runserver 0.0.0.0:8000
|
command: gunicorn pobsync_server.wsgi:application --bind 0.0.0.0:8000 --workers ${POBSYNC_GUNICORN_WORKERS:-2} --timeout ${POBSYNC_GUNICORN_TIMEOUT:-120}
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POBSYNC_DJANGO_DEBUG: "1"
|
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
|
||||||
POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me"
|
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
|
||||||
POBSYNC_DJANGO_ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0"
|
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
|
||||||
POBSYNC_HOME: "/opt/pobsync"
|
POBSYNC_HOME: "/opt/pobsync"
|
||||||
POBSYNC_DB_ENGINE: "mariadb"
|
POBSYNC_DB_ENGINE: "mariadb"
|
||||||
POBSYNC_DB_HOST: "db"
|
POBSYNC_DB_HOST: "db"
|
||||||
@@ -45,18 +80,25 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${POBSYNC_WEB_BIND:-0.0.0.0}:8010:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- pobsync_state:/opt/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
scheduler-mariadb:
|
scheduler-mariadb:
|
||||||
profiles: ["mariadb"]
|
profiles: ["mariadb"]
|
||||||
build: .
|
build: .
|
||||||
command: python manage.py run_pobsync_scheduler --loop --interval 60
|
command: python manage.py run_pobsync_scheduler --loop --interval 60
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POBSYNC_DJANGO_DEBUG: "1"
|
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
|
||||||
POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me"
|
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
|
||||||
POBSYNC_DJANGO_ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0"
|
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
|
||||||
POBSYNC_HOME: "/opt/pobsync"
|
POBSYNC_HOME: "/opt/pobsync"
|
||||||
POBSYNC_DB_ENGINE: "mariadb"
|
POBSYNC_DB_ENGINE: "mariadb"
|
||||||
POBSYNC_DB_HOST: "db"
|
POBSYNC_DB_HOST: "db"
|
||||||
@@ -68,10 +110,44 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- pobsync_state:/opt/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
worker-mariadb:
|
||||||
|
profiles: ["mariadb"]
|
||||||
|
build: .
|
||||||
|
command: python manage.py run_pobsync_worker --loop --interval 15
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}"
|
||||||
|
POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}"
|
||||||
|
POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}"
|
||||||
|
POBSYNC_HOME: "/opt/pobsync"
|
||||||
|
POBSYNC_DB_ENGINE: "mariadb"
|
||||||
|
POBSYNC_DB_HOST: "db"
|
||||||
|
POBSYNC_DB_NAME: "pobsync"
|
||||||
|
POBSYNC_DB_USER: "pobsync"
|
||||||
|
POBSYNC_DB_PASSWORD: "pobsync"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- pobsync_state:/opt/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
db:
|
db:
|
||||||
profiles: ["mariadb"]
|
profiles: ["mariadb"]
|
||||||
image: mariadb:11
|
image: mariadb:11
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MARIADB_DATABASE: "pobsync"
|
MARIADB_DATABASE: "pobsync"
|
||||||
MARIADB_USER: "pobsync"
|
MARIADB_USER: "pobsync"
|
||||||
|
|||||||
177
docs/development.md
Normal file
177
docs/development.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Development Notes
|
||||||
|
|
||||||
|
This document contains development and optional Docker workflows. The recommended production path is the native
|
||||||
|
systemd installer documented in the README.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m pip install -e .
|
||||||
|
mkdir -p var
|
||||||
|
python3 manage.py migrate
|
||||||
|
python3 manage.py createsuperuser
|
||||||
|
python3 manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin is available at:
|
||||||
|
|
||||||
|
- http://127.0.0.1:8000/
|
||||||
|
- http://127.0.0.1:8000/admin/
|
||||||
|
|
||||||
|
Staff-only JSON endpoints are available at:
|
||||||
|
|
||||||
|
- http://127.0.0.1:8000/api/
|
||||||
|
- http://127.0.0.1:8000/api/status/
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
The project test suite is currently run through the Docker image so the runtime dependencies match deployment:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose build web scheduler worker
|
||||||
|
docker compose run --rm web python manage.py test pobsync_backend --verbosity 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintainer CLI
|
||||||
|
|
||||||
|
The Django UI is the normal operating surface. The `pobsync` entrypoint and direct `manage.py` commands are kept for
|
||||||
|
debugging, automated maintenance, and migrations. Prefer using the control panel for day-to-day host configuration,
|
||||||
|
schedule changes, manual backup queueing, snapshot discovery, retention planning, and SSH credential management.
|
||||||
|
|
||||||
|
Useful checks:
|
||||||
|
|
||||||
|
```
|
||||||
|
pobsync django check
|
||||||
|
python3 manage.py showmigrations pobsync_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
The short `pobsync` aliases are limited to operational actions that are useful while debugging a running install.
|
||||||
|
Configuration aliases are intentionally not public commands; use the Django UI or explicit management commands instead.
|
||||||
|
|
||||||
|
Worker and scheduler commands are normally run by systemd services:
|
||||||
|
|
||||||
|
```
|
||||||
|
pobsync worker --loop --interval 15
|
||||||
|
pobsync scheduler --loop --interval 60
|
||||||
|
```
|
||||||
|
|
||||||
|
One-off maintenance commands are still available when the UI is not the right tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
pobsync backup <host> --dry-run
|
||||||
|
pobsync discover-snapshots --host <host>
|
||||||
|
pobsync retention <host>
|
||||||
|
```
|
||||||
|
|
||||||
|
For scripted configuration changes, call the Django management command explicitly so it is clear that this is an
|
||||||
|
automation/debugging path rather than the normal UI workflow:
|
||||||
|
|
||||||
|
```
|
||||||
|
pobsync django configure_pobsync_host <host> --address <host.example>
|
||||||
|
pobsync django configure_pobsync_schedule <host> --schedule-expression "15 2 * * *"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installer Development
|
||||||
|
|
||||||
|
The native installer is interactive by default when stdin is a terminal. It should keep every prompt backed by a command
|
||||||
|
line flag or environment variable so production installs remain scriptable.
|
||||||
|
|
||||||
|
Useful modes:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo scripts/install-systemd
|
||||||
|
sudo scripts/install-systemd --non-interactive
|
||||||
|
sudo scripts/install-systemd --verbose
|
||||||
|
sudo scripts/install-systemd --create-superuser --superuser-username admin
|
||||||
|
sudo scripts/update-systemd
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer should print a short completion summary with the control panel URL, Self Check reminder, and service log
|
||||||
|
commands. Keep normal output user-facing: pobsync step names with OK, FAILED, or SKIPPED. Full apt, pip, Django, and
|
||||||
|
systemd output belongs behind `--verbose` or in the failed step output.
|
||||||
|
|
||||||
|
The updater is intentionally a small wrapper around the installer for routine production deploys. It should stay
|
||||||
|
non-interactive, preserve the existing environment file, skip OS package installation, skip superuser creation, and still
|
||||||
|
run the Django/runtime refresh steps needed after a code update.
|
||||||
|
|
||||||
|
## Docker With SQLite
|
||||||
|
|
||||||
|
Docker Compose is useful for local development and disposable test installs. Native systemd is preferred for production
|
||||||
|
backup servers.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up --build web
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts Django on:
|
||||||
|
|
||||||
|
- http://127.0.0.1:8010/
|
||||||
|
- http://127.0.0.1:8010/admin/
|
||||||
|
- http://127.0.0.1:8010/api/
|
||||||
|
- http://127.0.0.1:8010/api/status/
|
||||||
|
|
||||||
|
Run the scheduler alongside the web admin:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up --build web scheduler worker
|
||||||
|
```
|
||||||
|
|
||||||
|
The web service runs Django through Gunicorn and serves static files with WhiteNoise. The container persists
|
||||||
|
`/opt/pobsync` and the SQLite database in Docker volumes.
|
||||||
|
|
||||||
|
Backup data is always available at `/backups` inside the containers. By default this uses `./backups` on the host.
|
||||||
|
Override the host-side mount with `POBSYNC_BACKUP_ROOT`:
|
||||||
|
|
||||||
|
```
|
||||||
|
POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync docker compose up --build web scheduler worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker With MariaDB
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose --profile mariadb up --build web-mariadb
|
||||||
|
```
|
||||||
|
|
||||||
|
With the scheduler:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose --profile mariadb up --build web-mariadb scheduler-mariadb worker-mariadb
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite remains the default because it is enough for a single backup server and keeps deployment simple.
|
||||||
|
|
||||||
|
For native systemd installs with MariaDB client support, run the installer with:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo scripts/install-systemd --install-extras mariadb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
The public operating surface is Django-first. The CLI is now a maintainer layer around Django management commands and
|
||||||
|
the old YAML/cron workflow has been retired from the `pobsync` entrypoint.
|
||||||
|
Discovered snapshots are stored in `SnapshotRecord`, including the base snapshot metadata and a nullable SQL link to the
|
||||||
|
base record when it is known.
|
||||||
|
|
||||||
|
The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem.
|
||||||
|
Post-backup pruning from Django also uses the SQL retention service after the completed snapshot is recorded.
|
||||||
|
Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection.
|
||||||
|
Staff-only dashboard views expose the same operational state through Django templates.
|
||||||
|
Host pages include a safe snapshot discovery action that records existing snapshots into SQL.
|
||||||
|
Host pages also include a read-only SQL retention plan view before any destructive pruning action.
|
||||||
|
Schedules can be created or updated from host pages using the same SQL-backed scheduler model.
|
||||||
|
Host config can be edited from host pages while keeping host identity stable.
|
||||||
|
|
||||||
|
The remaining internal engine code still contains reusable backup primitives:
|
||||||
|
|
||||||
|
- snapshot naming and metadata
|
||||||
|
- rsync command construction and execution
|
||||||
|
- retention planning and pruning
|
||||||
|
- host locking
|
||||||
|
|
||||||
|
Next refactor targets:
|
||||||
|
|
||||||
|
- Move more snapshot lifecycle details into typed domain objects.
|
||||||
|
- Replace remaining dictionary-shaped config at engine boundaries.
|
||||||
@@ -4,11 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pobsync"
|
name = "pobsync"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Django>=5.2,<6.0",
|
"Django>=5.2,<6.0",
|
||||||
|
"gunicorn>=23.0,<24.0",
|
||||||
|
"whitenoise>=6.9,<7.0",
|
||||||
"PyYAML>=6.0"
|
"PyYAML>=6.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Deploy pobsync runtime into /opt/pobsync without pip/venv.
|
|
||||||
# Copies python package sources into /opt/pobsync/lib and installs a stable entrypoint in /opt/pobsync/bin.
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
PREFIX="/opt/pobsync"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: $0 [--prefix /opt/pobsync]" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--prefix)
|
|
||||||
[ $# -ge 2 ] || usage
|
|
||||||
PREFIX="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown arg: $1" >&2
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Determine repo root from this script location
|
|
||||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
||||||
REPO_ROOT="$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
|
|
||||||
SRC_PKG="${REPO_ROOT}/src/pobsync"
|
|
||||||
if [ ! -d "${SRC_PKG}" ]; then
|
|
||||||
echo "ERROR: expected python package at ${SRC_PKG}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BIN_DIR="${PREFIX}/bin"
|
|
||||||
LIB_DIR="${PREFIX}/lib"
|
|
||||||
DST_PKG="${LIB_DIR}/pobsync"
|
|
||||||
BUILD_FILE="${DST_PKG}/_build.txt"
|
|
||||||
|
|
||||||
mkdir -p "${BIN_DIR}" "${LIB_DIR}"
|
|
||||||
|
|
||||||
# Copy code into /opt/pobsync/lib/pobsync
|
|
||||||
# We use rsync if available (clean updates with --delete), otherwise fall back to cp -a.
|
|
||||||
if command -v rsync >/dev/null 2>&1; then
|
|
||||||
rsync -a --delete \
|
|
||||||
--exclude '__pycache__/' \
|
|
||||||
--exclude '*.pyc' \
|
|
||||||
--exclude '*.pyo' \
|
|
||||||
--exclude '*.pyd' \
|
|
||||||
"${SRC_PKG}/" "${DST_PKG}/"
|
|
||||||
else
|
|
||||||
# Fallback: wipe + copy
|
|
||||||
rm -rf "${DST_PKG}"
|
|
||||||
mkdir -p "${DST_PKG}"
|
|
||||||
cp -a "${SRC_PKG}/." "${DST_PKG}/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Write build info (best-effort)
|
|
||||||
GIT_SHA="unknown"
|
|
||||||
if command -v git >/dev/null 2>&1 && [ -d "${REPO_ROOT}/.git" ]; then
|
|
||||||
GIT_SHA="$(cd "${REPO_ROOT}" && git rev-parse HEAD 2>/dev/null || echo unknown)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
NOW_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo unknown)"
|
|
||||||
{
|
|
||||||
echo "deployed_at_utc=${NOW_UTC}"
|
|
||||||
echo "git_sha=${GIT_SHA}"
|
|
||||||
echo "repo_root=${REPO_ROOT}"
|
|
||||||
} > "${BUILD_FILE}"
|
|
||||||
|
|
||||||
# Install stable entrypoint that always runs code from /opt/pobsync/lib
|
|
||||||
WRAPPER="${BIN_DIR}/pobsync"
|
|
||||||
cat > "${WRAPPER}" <<EOF
|
|
||||||
#!/bin/sh
|
|
||||||
# managed-by=pobsync deploy
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
PREFIX="${PREFIX}"
|
|
||||||
export PYTHONPATH="\${PREFIX}/lib"
|
|
||||||
export PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
exec /usr/bin/python3 -m pobsync "\$@"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
chmod 0755 "${WRAPPER}"
|
|
||||||
|
|
||||||
echo "OK"
|
|
||||||
echo "- deployed package to ${DST_PKG}"
|
|
||||||
echo "- wrote build info ${BUILD_FILE}"
|
|
||||||
echo "- installed entrypoint ${WRAPPER}"
|
|
||||||
|
|
||||||
@@ -4,5 +4,6 @@ set -eu
|
|||||||
mkdir -p "$(dirname "${POBSYNC_SQLITE_PATH:-/var/lib/pobsync/pobsync.sqlite3}")"
|
mkdir -p "$(dirname "${POBSYNC_SQLITE_PATH:-/var/lib/pobsync/pobsync.sqlite3}")"
|
||||||
|
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
python manage.py collectstatic --noinput --clear
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
594
scripts/install-systemd
Executable file
594
scripts/install-systemd
Executable file
@@ -0,0 +1,594 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SOURCE_DIR=${POBSYNC_SOURCE_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
|
||||||
|
APP_DIR=${POBSYNC_APP_DIR:-/opt/pobsync/app}
|
||||||
|
VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv}
|
||||||
|
ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env}
|
||||||
|
SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync}
|
||||||
|
SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync}
|
||||||
|
INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-}
|
||||||
|
SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
|
||||||
|
ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||||
|
CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-}
|
||||||
|
BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups}
|
||||||
|
BACKUP_ROOT_EXPLICIT=0
|
||||||
|
if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then
|
||||||
|
BACKUP_ROOT_EXPLICIT=1
|
||||||
|
fi
|
||||||
|
WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010}
|
||||||
|
TIME_ZONE=${POBSYNC_TIME_ZONE:-}
|
||||||
|
FORCE_ENV=0
|
||||||
|
INSTALL_OS_PACKAGES=1
|
||||||
|
WITH_NGINX=0
|
||||||
|
VERBOSE=0
|
||||||
|
INTERACTIVE=0
|
||||||
|
CREATE_SUPERUSER=ask
|
||||||
|
SUPERUSER_USERNAME=${POBSYNC_SUPERUSER_USERNAME:-}
|
||||||
|
SUPERUSER_EMAIL=${POBSYNC_SUPERUSER_EMAIL:-}
|
||||||
|
SUPERUSER_PASSWORD=${POBSYNC_SUPERUSER_PASSWORD:-}
|
||||||
|
|
||||||
|
if [ -t 0 ]; then
|
||||||
|
INTERACTIVE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--source-dir)
|
||||||
|
SOURCE_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--app-dir)
|
||||||
|
APP_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--venv-dir)
|
||||||
|
VENV_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--env-file)
|
||||||
|
ENV_FILE=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--service-user)
|
||||||
|
SERVICE_USER=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--service-group)
|
||||||
|
SERVICE_GROUP=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--backup-root)
|
||||||
|
BACKUP_ROOT=$2
|
||||||
|
BACKUP_ROOT_EXPLICIT=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--allowed-hosts)
|
||||||
|
ALLOWED_HOSTS=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--csrf-trusted-origins)
|
||||||
|
CSRF_TRUSTED_ORIGINS=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-bind)
|
||||||
|
WEB_BIND=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--time-zone)
|
||||||
|
TIME_ZONE=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--force-env)
|
||||||
|
FORCE_ENV=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--verbose)
|
||||||
|
VERBOSE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--interactive)
|
||||||
|
INTERACTIVE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--non-interactive)
|
||||||
|
INTERACTIVE=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-install-os-packages)
|
||||||
|
INSTALL_OS_PACKAGES=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--install-extras)
|
||||||
|
INSTALL_EXTRAS=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--with-nginx)
|
||||||
|
WITH_NGINX=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--server-name)
|
||||||
|
SERVER_NAME=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--create-superuser)
|
||||||
|
CREATE_SUPERUSER=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-create-superuser)
|
||||||
|
CREATE_SUPERUSER=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--superuser-username)
|
||||||
|
SUPERUSER_USERNAME=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--superuser-email)
|
||||||
|
SUPERUSER_EMAIL=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--superuser-password)
|
||||||
|
SUPERUSER_PASSWORD=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Run this installer as root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ] && [ "$FORCE_ENV" -ne 1 ] && [ "$BACKUP_ROOT_EXPLICIT" -ne 1 ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then
|
||||||
|
BACKUP_ROOT=$POBSYNC_BACKUP_ROOT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
detect_time_zone() {
|
||||||
|
if [ -n "$TIME_ZONE" ]; then
|
||||||
|
printf '%s\n' "$TIME_ZONE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${POBSYNC_TIME_ZONE:-}" ]; then
|
||||||
|
printf '%s\n' "$POBSYNC_TIME_ZONE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v timedatectl >/dev/null 2>&1; then
|
||||||
|
detected=$(timedatectl show -p Timezone --value 2>/dev/null || true)
|
||||||
|
if [ -n "$detected" ]; then
|
||||||
|
printf '%s\n' "$detected"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f /etc/timezone ]; then
|
||||||
|
detected=$(sed -n '1p' /etc/timezone | tr -d '[:space:]')
|
||||||
|
if [ -n "$detected" ]; then
|
||||||
|
printf '%s\n' "$detected"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'UTC\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
TIME_ZONE=$(detect_time_zone)
|
||||||
|
|
||||||
|
run_step() {
|
||||||
|
label=$1
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [ "$VERBOSE" -eq 1 ]; then
|
||||||
|
echo "==> $label"
|
||||||
|
"$@"
|
||||||
|
echo "OK: $label"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%-48s' "$label"
|
||||||
|
log_file=$(mktemp)
|
||||||
|
if "$@" >"$log_file" 2>&1; then
|
||||||
|
rm -f "$log_file"
|
||||||
|
echo "OK"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "FAILED"
|
||||||
|
echo
|
||||||
|
echo "Output from failed step '$label':" >&2
|
||||||
|
cat "$log_file" >&2
|
||||||
|
rm -f "$log_file"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
note_step() {
|
||||||
|
label=$1
|
||||||
|
status=$2
|
||||||
|
printf '%-48s%s\n' "$label" "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_value() {
|
||||||
|
prompt=$1
|
||||||
|
default=$2
|
||||||
|
if [ "$INTERACTIVE" -ne 1 ]; then
|
||||||
|
printf '%s\n' "$default"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s [%s]: ' "$prompt" "$default" >&2
|
||||||
|
read -r answer
|
||||||
|
if [ -n "$answer" ]; then
|
||||||
|
printf '%s\n' "$answer"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$default"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_yes_no() {
|
||||||
|
prompt=$1
|
||||||
|
default=$2
|
||||||
|
if [ "$INTERACTIVE" -ne 1 ]; then
|
||||||
|
printf '%s\n' "$default"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$default" -eq 1 ]; then
|
||||||
|
suffix=Y/n
|
||||||
|
else
|
||||||
|
suffix=y/N
|
||||||
|
fi
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
printf '%s [%s]: ' "$prompt" "$suffix" >&2
|
||||||
|
read -r answer
|
||||||
|
case "$answer" in
|
||||||
|
"")
|
||||||
|
printf '%s\n' "$default"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
y|Y|yes|YES|Yes)
|
||||||
|
printf '1\n'
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
n|N|no|NO|No)
|
||||||
|
printf '0\n'
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_secret() {
|
||||||
|
prompt=$1
|
||||||
|
if [ "$INTERACTIVE" -ne 1 ]; then
|
||||||
|
printf '\n'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s: ' "$prompt" >&2
|
||||||
|
stty -echo
|
||||||
|
read -r secret
|
||||||
|
stty echo
|
||||||
|
printf '\n' >&2
|
||||||
|
printf '%s\n' "$secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$INTERACTIVE" -eq 1 ]; then
|
||||||
|
echo "pobsync native installer"
|
||||||
|
echo
|
||||||
|
echo "Press Enter to accept defaults. Existing command-line flags are already applied as defaults."
|
||||||
|
echo
|
||||||
|
|
||||||
|
SOURCE_DIR=$(prompt_value "Source checkout" "$SOURCE_DIR")
|
||||||
|
APP_DIR=$(prompt_value "Install app directory" "$APP_DIR")
|
||||||
|
VENV_DIR=$(prompt_value "Python virtualenv directory" "$VENV_DIR")
|
||||||
|
ENV_FILE=$(prompt_value "Environment file" "$ENV_FILE")
|
||||||
|
SERVICE_USER=$(prompt_value "Service user" "$SERVICE_USER")
|
||||||
|
SERVICE_GROUP=$(prompt_value "Service group" "$SERVICE_GROUP")
|
||||||
|
BACKUP_ROOT=$(prompt_value "Backup storage path" "$BACKUP_ROOT")
|
||||||
|
WEB_BIND=$(prompt_value "Gunicorn bind address" "$WEB_BIND")
|
||||||
|
TIME_ZONE=$(prompt_value "Scheduler time zone" "$TIME_ZONE")
|
||||||
|
ALLOWED_HOSTS=$(prompt_value "Allowed hosts" "$ALLOWED_HOSTS")
|
||||||
|
CSRF_TRUSTED_ORIGINS=$(prompt_value "CSRF trusted origins, comma-separated or blank" "$CSRF_TRUSTED_ORIGINS")
|
||||||
|
INSTALL_OS_PACKAGES=$(prompt_yes_no "Install required OS packages with apt-get" "$INSTALL_OS_PACKAGES")
|
||||||
|
|
||||||
|
use_mariadb=0
|
||||||
|
if [ "$INSTALL_EXTRAS" = "mariadb" ] || [ "$INSTALL_EXTRAS" = "[mariadb]" ] || [ "$INSTALL_EXTRAS" = ".[mariadb]" ]; then
|
||||||
|
use_mariadb=1
|
||||||
|
fi
|
||||||
|
use_mariadb=$(prompt_yes_no "Install MariaDB Python/client support" "$use_mariadb")
|
||||||
|
if [ "$use_mariadb" -eq 1 ]; then
|
||||||
|
INSTALL_EXTRAS=mariadb
|
||||||
|
else
|
||||||
|
INSTALL_EXTRAS=
|
||||||
|
fi
|
||||||
|
|
||||||
|
WITH_NGINX=$(prompt_yes_no "Install starter nginx reverse proxy config" "$WITH_NGINX")
|
||||||
|
if [ "$WITH_NGINX" -eq 1 ]; then
|
||||||
|
SERVER_NAME=$(prompt_value "Nginx server_name" "$SERVER_NAME")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$CREATE_SUPERUSER" = "ask" ]; then
|
||||||
|
CREATE_SUPERUSER=$(prompt_yes_no "Create first Django superuser after install" 1)
|
||||||
|
fi
|
||||||
|
if [ "$CREATE_SUPERUSER" -eq 1 ]; then
|
||||||
|
SUPERUSER_USERNAME=$(prompt_value "Superuser username" "${SUPERUSER_USERNAME:-admin}")
|
||||||
|
SUPERUSER_EMAIL=$(prompt_value "Superuser email, blank allowed" "$SUPERUSER_EMAIL")
|
||||||
|
if [ -z "$SUPERUSER_PASSWORD" ]; then
|
||||||
|
SUPERUSER_PASSWORD=$(prompt_secret "Superuser password, leave blank to run createsuperuser interactively later")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$CREATE_SUPERUSER" = "ask" ]; then
|
||||||
|
if [ -n "$SUPERUSER_USERNAME" ] && [ -n "$SUPERUSER_PASSWORD" ]; then
|
||||||
|
CREATE_SUPERUSER=1
|
||||||
|
else
|
||||||
|
CREATE_SUPERUSER=0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_os_packages() {
|
||||||
|
if [ "$INSTALL_OS_PACKAGES" -ne 1 ]; then
|
||||||
|
note_step "Install OS packages" "SKIPPED"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
packages="python3 python3-venv python3-pip rsync openssh-client"
|
||||||
|
if [ "$WITH_NGINX" -eq 1 ]; then
|
||||||
|
packages="$packages nginx"
|
||||||
|
fi
|
||||||
|
if [ "$INSTALL_EXTRAS" = "mariadb" ] || [ "$INSTALL_EXTRAS" = "[mariadb]" ] || [ "$INSTALL_EXTRAS" = ".[mariadb]" ]; then
|
||||||
|
packages="$packages default-libmysqlclient-dev build-essential pkg-config"
|
||||||
|
fi
|
||||||
|
run_step "Install OS packages" sh -c "apt-get update && apt-get install -y --no-install-recommends $packages"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "No supported package manager found; install python3, python3-venv, rsync, and openssh-client manually." >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
install_os_packages
|
||||||
|
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "python3 is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! env POBSYNC_INSTALL_TIME_ZONE="$TIME_ZONE" python3 -c "import os; from zoneinfo import ZoneInfo; ZoneInfo(os.environ['POBSYNC_INSTALL_TIME_ZONE'])" >/dev/null 2>&1; then
|
||||||
|
echo "Invalid time zone: $TIME_ZONE" >&2
|
||||||
|
echo "Use an IANA timezone such as UTC or Europe/Amsterdam." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v rsync >/dev/null 2>&1; then
|
||||||
|
echo "rsync is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v ssh >/dev/null 2>&1; then
|
||||||
|
echo "openssh-client is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$SOURCE_DIR/manage.py" ]; then
|
||||||
|
echo "Source directory does not look like a pobsync checkout: $SOURCE_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||||
|
run_step "Create service group" groupadd --system "$SERVICE_GROUP"
|
||||||
|
else
|
||||||
|
note_step "Create service group" "OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||||
|
run_step "Create service user" useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
|
||||||
|
else
|
||||||
|
note_step "Create service user" "OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
grant_journal_access() {
|
||||||
|
for group in systemd-journal adm; do
|
||||||
|
if getent group "$group" >/dev/null 2>&1; then
|
||||||
|
usermod -a -G "$group" "$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step "Grant journal access" grant_journal_access
|
||||||
|
run_step "Prepare directories" mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT"
|
||||||
|
run_step "Set state directory permissions" chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT"
|
||||||
|
run_step "Set private directory modes" chmod 0750 /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT"
|
||||||
|
|
||||||
|
if [ "$SOURCE_DIR" != "$APP_DIR" ]; then
|
||||||
|
run_step "Sync application files" rsync -a --delete \
|
||||||
|
--exclude .git \
|
||||||
|
--exclude .venv \
|
||||||
|
--exclude __pycache__ \
|
||||||
|
--exclude .pytest_cache \
|
||||||
|
--exclude .mypy_cache \
|
||||||
|
--exclude var \
|
||||||
|
"$SOURCE_DIR"/ "$APP_DIR"/
|
||||||
|
else
|
||||||
|
note_step "Sync application files" "SKIPPED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_step "Create Python virtualenv" python3 -m venv "$VENV_DIR"
|
||||||
|
run_step "Upgrade pip" "$VENV_DIR/bin/python" -m pip install --upgrade pip
|
||||||
|
case "$INSTALL_EXTRAS" in
|
||||||
|
"")
|
||||||
|
pip_target=$APP_DIR
|
||||||
|
;;
|
||||||
|
mariadb)
|
||||||
|
pip_target="$APP_DIR[mariadb]"
|
||||||
|
;;
|
||||||
|
\[*\])
|
||||||
|
pip_target="$APP_DIR$INSTALL_EXTRAS"
|
||||||
|
;;
|
||||||
|
.\[*\])
|
||||||
|
pip_target="$APP_DIR${INSTALL_EXTRAS#.}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported install extras: $INSTALL_EXTRAS" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
run_step "Install Python package" "$VENV_DIR/bin/python" -m pip install -e "$pip_target"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ] || [ "$FORCE_ENV" -eq 1 ]; then
|
||||||
|
secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
POBSYNC_DJANGO_DEBUG=0
|
||||||
|
POBSYNC_DJANGO_SECRET_KEY=$secret
|
||||||
|
POBSYNC_DJANGO_ALLOWED_HOSTS=$ALLOWED_HOSTS
|
||||||
|
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=$CSRF_TRUSTED_ORIGINS
|
||||||
|
|
||||||
|
POBSYNC_HOME=/var/lib/pobsync
|
||||||
|
POBSYNC_BACKUP_ROOT=$BACKUP_ROOT
|
||||||
|
POBSYNC_TIME_ZONE=$TIME_ZONE
|
||||||
|
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
|
||||||
|
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static
|
||||||
|
POBSYNC_ENV_FILE=$ENV_FILE
|
||||||
|
POBSYNC_SERVICE_USER=$SERVICE_USER
|
||||||
|
POBSYNC_SERVICE_GROUP=$SERVICE_GROUP
|
||||||
|
|
||||||
|
POBSYNC_WEB_BIND=$WEB_BIND
|
||||||
|
POBSYNC_GUNICORN_WORKERS=2
|
||||||
|
POBSYNC_GUNICORN_TIMEOUT=120
|
||||||
|
POBSYNC_WORKER_INTERVAL=15
|
||||||
|
POBSYNC_SCHEDULER_INTERVAL=60
|
||||||
|
EOF
|
||||||
|
chmod 0640 "$ENV_FILE"
|
||||||
|
chown "root:$SERVICE_GROUP" "$ENV_FILE"
|
||||||
|
note_step "Write environment file" "OK"
|
||||||
|
else
|
||||||
|
note_step "Write environment file" "SKIPPED"
|
||||||
|
echo "Keeping existing $ENV_FILE. Use --force-env to rewrite it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
install_unit() {
|
||||||
|
src=$1
|
||||||
|
dest=$2
|
||||||
|
sed \
|
||||||
|
-e "s|@POBSYNC_APP_DIR@|$APP_DIR|g" \
|
||||||
|
-e "s|@POBSYNC_VENV_DIR@|$VENV_DIR|g" \
|
||||||
|
-e "s|@POBSYNC_ENV_FILE@|$ENV_FILE|g" \
|
||||||
|
-e "s|@POBSYNC_USER@|$SERVICE_USER|g" \
|
||||||
|
-e "s|@POBSYNC_GROUP@|$SERVICE_GROUP|g" \
|
||||||
|
"$src" > "$dest"
|
||||||
|
chmod 0644 "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_units() {
|
||||||
|
install_unit "$APP_DIR/deploy/systemd/pobsync-web.service" /etc/systemd/system/pobsync-web.service
|
||||||
|
install_unit "$APP_DIR/deploy/systemd/pobsync-worker.service" /etc/systemd/system/pobsync-worker.service
|
||||||
|
install_unit "$APP_DIR/deploy/systemd/pobsync-scheduler.service" /etc/systemd/system/pobsync-scheduler.service
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step "Install systemd units" install_units
|
||||||
|
|
||||||
|
install_manage_wrapper() {
|
||||||
|
sed \
|
||||||
|
-e "s|@POBSYNC_APP_DIR@|$APP_DIR|g" \
|
||||||
|
-e "s|@POBSYNC_VENV_DIR@|$VENV_DIR|g" \
|
||||||
|
-e "s|@POBSYNC_ENV_FILE@|$ENV_FILE|g" \
|
||||||
|
-e "s|@POBSYNC_USER@|$SERVICE_USER|g" \
|
||||||
|
-e "s|@POBSYNC_GROUP@|$SERVICE_GROUP|g" \
|
||||||
|
"$APP_DIR/deploy/bin/pobsync-manage" > /usr/local/bin/pobsync-manage
|
||||||
|
chmod 0755 /usr/local/bin/pobsync-manage
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step "Install manage wrapper" install_manage_wrapper
|
||||||
|
|
||||||
|
run_step "Reload systemd" systemctl daemon-reload
|
||||||
|
run_step "Run database migrations" /usr/local/bin/pobsync-manage migrate --noinput
|
||||||
|
run_step "Ensure default SSH key" /usr/local/bin/pobsync-manage ensure_pobsync_ssh_key --name default --set-global-default
|
||||||
|
run_step "Collect static files" /usr/local/bin/pobsync-manage collectstatic --noinput --clear
|
||||||
|
run_step "Finalize state permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
|
||||||
|
|
||||||
|
superuser_exists=$("$VENV_DIR/bin/python" -c "import os; os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pobsync_server.settings'); import django; django.setup(); from django.contrib.auth import get_user_model; print('yes' if get_user_model().objects.filter(is_superuser=True).exists() else 'no')")
|
||||||
|
if [ "$CREATE_SUPERUSER" -eq 1 ]; then
|
||||||
|
if [ "$superuser_exists" = "yes" ]; then
|
||||||
|
note_step "Create Django superuser" "SKIPPED"
|
||||||
|
elif [ -n "$SUPERUSER_USERNAME" ] && [ -n "$SUPERUSER_PASSWORD" ]; then
|
||||||
|
run_step "Create Django superuser" env \
|
||||||
|
DJANGO_SUPERUSER_USERNAME="$SUPERUSER_USERNAME" \
|
||||||
|
DJANGO_SUPERUSER_EMAIL="$SUPERUSER_EMAIL" \
|
||||||
|
DJANGO_SUPERUSER_PASSWORD="$SUPERUSER_PASSWORD" \
|
||||||
|
/usr/local/bin/pobsync-manage createsuperuser --noinput
|
||||||
|
run_step "Finalize superuser permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
|
||||||
|
else
|
||||||
|
note_step "Create Django superuser" "SKIPPED"
|
||||||
|
echo "No superuser password was provided; create one later with:"
|
||||||
|
echo " sudo -u $SERVICE_USER pobsync-manage createsuperuser"
|
||||||
|
fi
|
||||||
|
elif [ "$superuser_exists" != "yes" ]; then
|
||||||
|
note_step "Create Django superuser" "SKIPPED"
|
||||||
|
echo "No Django superuser exists yet. Create one with:"
|
||||||
|
echo " sudo -u $SERVICE_USER pobsync-manage createsuperuser"
|
||||||
|
else
|
||||||
|
note_step "Create Django superuser" "SKIPPED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_step "Enable services" systemctl enable pobsync-web.service pobsync-worker.service pobsync-scheduler.service
|
||||||
|
run_step "Restart services" systemctl restart pobsync-web.service pobsync-worker.service pobsync-scheduler.service
|
||||||
|
|
||||||
|
if [ "$WITH_NGINX" -eq 1 ]; then
|
||||||
|
if ! command -v nginx >/dev/null 2>&1; then
|
||||||
|
note_step "Install nginx config" "SKIPPED"
|
||||||
|
echo "nginx is not installed; skipping nginx config." >&2
|
||||||
|
else
|
||||||
|
sed "s|@POBSYNC_SERVER_NAME@|$SERVER_NAME|g" "$APP_DIR/deploy/nginx/pobsync.conf" > /etc/nginx/sites-available/pobsync.conf
|
||||||
|
ln -sf /etc/nginx/sites-available/pobsync.conf /etc/nginx/sites-enabled/pobsync.conf
|
||||||
|
note_step "Install nginx config" "OK"
|
||||||
|
run_step "Validate nginx config" nginx -t
|
||||||
|
run_step "Reload nginx" systemctl reload nginx
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
note_step "Install nginx config" "SKIPPED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$VERBOSE" -eq 1 ]; then
|
||||||
|
systemctl --no-pager --full status pobsync-web.service pobsync-worker.service pobsync-scheduler.service || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "pobsync installation complete."
|
||||||
|
echo
|
||||||
|
echo "Open the Django control panel at:"
|
||||||
|
echo " http://$WEB_BIND/"
|
||||||
|
echo
|
||||||
|
echo "If pobsync is behind a reverse proxy, use your public hostname instead."
|
||||||
|
echo
|
||||||
|
echo "Recommended first steps:"
|
||||||
|
echo " 1. Log in to the Django control panel."
|
||||||
|
echo " 2. Open Self Check and resolve any warnings."
|
||||||
|
echo " 3. Configure global settings and backup storage."
|
||||||
|
echo " 4. Add an SSH key under SSH Keys."
|
||||||
|
echo " 5. Add a host and queue a dry-run backup."
|
||||||
|
echo
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " systemctl status pobsync-web pobsync-worker pobsync-scheduler"
|
||||||
|
echo " journalctl -u pobsync-worker -f"
|
||||||
|
echo " sudo -u $SERVICE_USER pobsync-manage check"
|
||||||
|
echo " sudo -u $SERVICE_USER pobsync-manage check_pobsync_install"
|
||||||
41
scripts/update-systemd
Executable file
41
scripts/update-systemd
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: sudo scripts/update-systemd [options]
|
||||||
|
|
||||||
|
Refresh an existing native pobsync systemd install from the current checkout.
|
||||||
|
|
||||||
|
This is a thin, safer update wrapper around scripts/install-systemd. It keeps
|
||||||
|
the install non-interactive, preserves the existing environment file, skips
|
||||||
|
superuser creation, and skips OS package installation by default.
|
||||||
|
|
||||||
|
Common options are forwarded to install-systemd, for example:
|
||||||
|
--source-dir PATH
|
||||||
|
--app-dir PATH
|
||||||
|
--venv-dir PATH
|
||||||
|
--env-file PATH
|
||||||
|
--service-user USER
|
||||||
|
--service-group GROUP
|
||||||
|
--install-extras mariadb
|
||||||
|
--verbose
|
||||||
|
|
||||||
|
If OS packages need to be refreshed, run scripts/install-systemd directly.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exec "$SCRIPT_DIR/install-systemd" \
|
||||||
|
--non-interactive \
|
||||||
|
--no-install-os-packages \
|
||||||
|
--no-create-superuser \
|
||||||
|
"$@"
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "0.1.0"
|
__version__ = "1.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ from typing import Sequence
|
|||||||
|
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
from pobsync import __version__
|
||||||
|
|
||||||
|
|
||||||
COMMAND_ALIASES = {
|
COMMAND_ALIASES = {
|
||||||
"configure-global": "configure_pobsync_global",
|
|
||||||
"configure-host": "configure_pobsync_host",
|
|
||||||
"schedule": "configure_pobsync_schedule",
|
|
||||||
"backup": "run_pobsync_backup",
|
"backup": "run_pobsync_backup",
|
||||||
"retention": "run_pobsync_retention",
|
"retention": "run_pobsync_retention",
|
||||||
"discover-snapshots": "discover_pobsync_snapshots",
|
"discover-snapshots": "discover_pobsync_snapshots",
|
||||||
"scheduler": "run_pobsync_scheduler",
|
"scheduler": "run_pobsync_scheduler",
|
||||||
|
"worker": "run_pobsync_worker",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +28,17 @@ Usage:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
{commands}
|
{commands}
|
||||||
|
|
||||||
|
Configuration is managed from the Django control panel. Use
|
||||||
|
`pobsync django <management-command>` for automation or debugging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
args = list(sys.argv[1:] if argv is None else argv)
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if args and args[0] in {"--version", "version"}:
|
||||||
|
print(f"pobsync {__version__}")
|
||||||
|
return 0
|
||||||
if not args or args[0] in {"-h", "--help", "help"}:
|
if not args or args[0] in {"-h", "--help", "help"}:
|
||||||
print(_usage())
|
print(_usage())
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, List
|
||||||
|
|
||||||
from ..config.source import ConfigSource, FileConfigSource
|
from ..config.source import ConfigSource
|
||||||
from ..errors import ConfigError
|
from ..errors import ConfigError
|
||||||
from ..paths import PobsyncPaths
|
from ..retention import Snapshot, apply_base_protection, build_retention_plan
|
||||||
from ..retention import Snapshot, build_retention_plan
|
|
||||||
from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root
|
from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root
|
||||||
from ..util import sanitize_host
|
from ..util import sanitize_host
|
||||||
|
|
||||||
@@ -28,59 +27,6 @@ def _parse_snapshot_dt(dirname: str, meta: dict) -> datetime:
|
|||||||
return datetime.fromtimestamp(0, tz=timezone.utc)
|
return datetime.fromtimestamp(0, tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _apply_base_protection(
|
|
||||||
snapshots: List[Snapshot],
|
|
||||||
keep: set[str],
|
|
||||||
reasons: Dict[str, List[str]],
|
|
||||||
) -> Tuple[set[str], Dict[str, List[str]]]:
|
|
||||||
"""
|
|
||||||
Optional policy: if a kept snapshot has a base (kind+dirname), also keep that base snapshot.
|
|
||||||
This is NOT required for hardlink snapshots to remain readable, but can be useful
|
|
||||||
for performance (better base selection) or "chain" readability.
|
|
||||||
|
|
||||||
Adds reason: "base-of:<child_dirname>"
|
|
||||||
"""
|
|
||||||
# Index snapshots by (kind, dirname)
|
|
||||||
idx: Dict[Tuple[str, str], Snapshot] = {(s.kind, s.dirname): s for s in snapshots}
|
|
||||||
|
|
||||||
changed = True
|
|
||||||
while changed:
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
# Iterate over a stable list of current keep items
|
|
||||||
for child_dirname in list(keep):
|
|
||||||
# Find the child snapshot (may exist in multiple kinds; check both)
|
|
||||||
child: Optional[Snapshot] = None
|
|
||||||
for k in ("scheduled", "manual"):
|
|
||||||
child = idx.get((k, child_dirname))
|
|
||||||
if child is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
if child is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
base = child.base
|
|
||||||
if not isinstance(base, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
base_kind = base.get("kind")
|
|
||||||
base_dirname = base.get("dirname")
|
|
||||||
if not isinstance(base_kind, str) or not isinstance(base_dirname, str):
|
|
||||||
continue
|
|
||||||
|
|
||||||
base_snap = idx.get((base_kind, base_dirname))
|
|
||||||
if base_snap is None:
|
|
||||||
# Base might have been pruned already or never existed; ignore.
|
|
||||||
continue
|
|
||||||
|
|
||||||
if base_dirname not in keep:
|
|
||||||
keep.add(base_dirname)
|
|
||||||
reasons.setdefault(base_dirname, []).append(f"base-of:{child_dirname}")
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
return keep, reasons
|
|
||||||
|
|
||||||
|
|
||||||
def run_retention_plan(
|
def run_retention_plan(
|
||||||
prefix: Path,
|
prefix: Path,
|
||||||
host: str,
|
host: str,
|
||||||
@@ -93,10 +39,9 @@ def run_retention_plan(
|
|||||||
if kind not in {"scheduled", "manual", "all"}:
|
if kind not in {"scheduled", "manual", "all"}:
|
||||||
raise ConfigError("kind must be scheduled, manual, or all")
|
raise ConfigError("kind must be scheduled, manual, or all")
|
||||||
|
|
||||||
paths = PobsyncPaths(home=prefix)
|
if config_source is None:
|
||||||
|
raise ConfigError("A Django config source is required.")
|
||||||
source = config_source or FileConfigSource(prefix=paths.home)
|
cfg = config_source.effective_config_for_host(host)
|
||||||
cfg = source.effective_config_for_host(host)
|
|
||||||
|
|
||||||
retention = cfg.get("retention")
|
retention = cfg.get("retention")
|
||||||
if not isinstance(retention, dict):
|
if not isinstance(retention, dict):
|
||||||
@@ -142,7 +87,7 @@ def run_retention_plan(
|
|||||||
reasons = dict(plan.reasons)
|
reasons = dict(plan.reasons)
|
||||||
|
|
||||||
if protect_bases:
|
if protect_bases:
|
||||||
keep, reasons = _apply_base_protection(snapshots=snapshots, keep=keep, reasons=reasons)
|
keep, reasons = apply_base_protection(snapshots=snapshots, keep=keep, reasons=reasons)
|
||||||
|
|
||||||
delete = [s for s in snapshots if s.dirname not in keep]
|
delete = [s for s in snapshots if s.dirname not in keep]
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
from ..config.source import ConfigSource, FileConfigSource
|
from ..config.source import ConfigSource
|
||||||
from ..errors import ConfigError
|
from ..errors import ConfigError
|
||||||
from ..lock import acquire_host_lock
|
from ..lock import acquire_host_lock
|
||||||
from ..paths import PobsyncPaths
|
from ..paths import PobsyncPaths
|
||||||
from ..rsync import build_rsync_command, build_ssh_command, run_rsync
|
from ..rsync import build_rsync_command, build_ssh_command, run_rsync
|
||||||
|
from ..run_stats import collect_storage_stats, read_rsync_stats
|
||||||
from ..snapshot import (
|
from ..snapshot import (
|
||||||
HostBackupDirs,
|
HostBackupDirs,
|
||||||
extract_ts_and_id_from_dirname,
|
extract_ts_and_id_from_dirname,
|
||||||
@@ -21,6 +22,72 @@ from ..snapshot_meta import read_snapshot_meta
|
|||||||
from ..util import ensure_dir, realpath_startswith, sanitize_host, write_yaml_atomic
|
from ..util import ensure_dir, realpath_startswith, sanitize_host, write_yaml_atomic
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_DRY_RUN_TIMEOUT_SECONDS = 900
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run_log_path(host: str, run_id: int | None = None) -> Path:
|
||||||
|
host = sanitize_host(host)
|
||||||
|
run_dir = f"run-{run_id}" if run_id is not None else "adhoc"
|
||||||
|
return Path(f"/tmp/pobsync-dryrun/{host}/{run_dir}/rsync.log")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_tail(log_path: Path, *, max_lines: int = 40) -> list[str]:
|
||||||
|
try:
|
||||||
|
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return lines[-max_lines:]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_rsync_failure(exit_code: int | None, log_tail: list[str]) -> dict[str, str]:
|
||||||
|
joined_tail = "\n".join(log_tail).lower()
|
||||||
|
if exit_code == 255 and "broken pipe" in joined_tail:
|
||||||
|
return {
|
||||||
|
"category": "transport",
|
||||||
|
"message": "Rsync transport closed unexpectedly.",
|
||||||
|
"hint": "The SSH/rsync stream ended with a broken pipe. Check remote rsync availability, remote shell output, excludes, and connection stability.",
|
||||||
|
}
|
||||||
|
if exit_code == 255:
|
||||||
|
return {
|
||||||
|
"category": "transport",
|
||||||
|
"message": "Rsync transport failed.",
|
||||||
|
"hint": "Exit 255 usually comes from SSH or remote rsync startup. Check SSH access, known_hosts, remote rsync, and remote shell output.",
|
||||||
|
}
|
||||||
|
if exit_code == 124:
|
||||||
|
return {
|
||||||
|
"category": "timeout",
|
||||||
|
"message": "Rsync timed out.",
|
||||||
|
"hint": "Increase the rsync timeout or narrow the backup scope with source root, includes, or excludes.",
|
||||||
|
}
|
||||||
|
if "permission denied" in joined_tail:
|
||||||
|
return {
|
||||||
|
"category": "permissions",
|
||||||
|
"message": "Rsync hit a permission error.",
|
||||||
|
"hint": "Check the SSH user, key, and permissions on the remote source.",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"category": "rsync",
|
||||||
|
"message": "Rsync failed.",
|
||||||
|
"hint": "Check the rsync log tail for the underlying error.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_run_stats(
|
||||||
|
*,
|
||||||
|
log_path: Path,
|
||||||
|
backup_root: Path,
|
||||||
|
duration_seconds: int | None = None,
|
||||||
|
snapshot_dir: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
stats: dict[str, Any] = {
|
||||||
|
"rsync": read_rsync_stats(log_path),
|
||||||
|
"storage": collect_storage_stats(backup_root=backup_root, snapshot_dir=snapshot_dir),
|
||||||
|
}
|
||||||
|
if duration_seconds is not None:
|
||||||
|
stats["duration_seconds"] = int(duration_seconds)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def _host_backup_dirs(backup_root: str, host: str) -> HostBackupDirs:
|
def _host_backup_dirs(backup_root: str, host: str) -> HostBackupDirs:
|
||||||
return HostBackupDirs(root=Path(backup_root) / host)
|
return HostBackupDirs(root=Path(backup_root) / host)
|
||||||
|
|
||||||
@@ -88,13 +155,17 @@ def run_scheduled(
|
|||||||
prune_max_delete: int | None = None,
|
prune_max_delete: int | None = None,
|
||||||
prune_protect_bases: bool = False,
|
prune_protect_bases: bool = False,
|
||||||
config_source: ConfigSource | None = None,
|
config_source: ConfigSource | None = None,
|
||||||
|
run_id: int | None = None,
|
||||||
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
verbose_output: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
||||||
host = sanitize_host(host)
|
host = sanitize_host(host)
|
||||||
paths = PobsyncPaths(home=prefix)
|
paths = PobsyncPaths(home=prefix)
|
||||||
|
|
||||||
source = config_source or FileConfigSource(prefix=paths.home)
|
if config_source is None:
|
||||||
cfg = source.effective_config_for_host(host)
|
raise ConfigError("A Django config source is required.")
|
||||||
|
cfg = config_source.effective_config_for_host(host)
|
||||||
|
|
||||||
backup_root = cfg.get("backup_root")
|
backup_root = cfg.get("backup_root")
|
||||||
if not isinstance(backup_root, str) or not backup_root.startswith("/"):
|
if not isinstance(backup_root, str) or not backup_root.startswith("/"):
|
||||||
@@ -141,8 +212,10 @@ def run_scheduled(
|
|||||||
# DRY RUN
|
# DRY RUN
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
if dry_run:
|
if dry_run:
|
||||||
dest = f"/tmp/pobsync-dryrun/{host}/"
|
dryrun_log = dry_run_log_path(host, run_id=run_id)
|
||||||
dryrun_log = Path(f"/tmp/pobsync-dryrun/{host}/rsync.log")
|
dest = str(dryrun_log.parent) + "/"
|
||||||
|
dryrun_log.unlink(missing_ok=True)
|
||||||
|
effective_timeout_seconds = timeout_seconds or DEFAULT_DRY_RUN_TIMEOUT_SECONDS
|
||||||
|
|
||||||
cmd = build_rsync_command(
|
cmd = build_rsync_command(
|
||||||
rsync_binary=str(rsync_binary),
|
rsync_binary=str(rsync_binary),
|
||||||
@@ -152,25 +225,44 @@ def run_scheduled(
|
|||||||
dest=dest,
|
dest=dest,
|
||||||
link_dest=link_dest,
|
link_dest=link_dest,
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=effective_timeout_seconds,
|
||||||
bwlimit_kbps=bwlimit_kbps,
|
bwlimit_kbps=bwlimit_kbps,
|
||||||
extra_excludes=list(excludes),
|
extra_excludes=list(excludes),
|
||||||
extra_includes=list(includes),
|
extra_includes=list(includes),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = run_rsync(cmd, log_path=dryrun_log, timeout_seconds=timeout_seconds)
|
result = run_rsync(
|
||||||
|
cmd,
|
||||||
|
log_path=dryrun_log,
|
||||||
|
timeout_seconds=effective_timeout_seconds,
|
||||||
|
cancel_check=cancel_check,
|
||||||
|
)
|
||||||
|
log_tail = _read_log_tail(dryrun_log)
|
||||||
|
stats = _collect_run_stats(
|
||||||
|
log_path=dryrun_log,
|
||||||
|
backup_root=Path(backup_root),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
response = {
|
||||||
"ok": result.exit_code == 0,
|
"ok": result.exit_code == 0,
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
"host": host,
|
"host": host,
|
||||||
"base": str(base_dir) if base_dir else None,
|
"base": str(base_dir) if base_dir else None,
|
||||||
"log": str(dryrun_log),
|
"log": str(dryrun_log),
|
||||||
|
"cancelled": result.cancelled,
|
||||||
|
"timeout_seconds": effective_timeout_seconds,
|
||||||
|
"verbose_output": True,
|
||||||
|
"ssh_credential": cfg.get("ssh_credential"),
|
||||||
|
"stats": stats,
|
||||||
"rsync": {
|
"rsync": {
|
||||||
"exit_code": result.exit_code,
|
"exit_code": result.exit_code,
|
||||||
"command": result.command,
|
"command": result.command,
|
||||||
|
"log_tail": log_tail,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if result.exit_code != 0:
|
||||||
|
response["failure"] = classify_rsync_failure(result.exit_code, log_tail)
|
||||||
|
return response
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# REAL RUN
|
# REAL RUN
|
||||||
@@ -209,6 +301,7 @@ def run_scheduled(
|
|||||||
bwlimit_kbps=bwlimit_kbps,
|
bwlimit_kbps=bwlimit_kbps,
|
||||||
extra_excludes=list(excludes),
|
extra_excludes=list(excludes),
|
||||||
extra_includes=list(includes),
|
extra_includes=list(includes),
|
||||||
|
verbose_output=bool(verbose_output),
|
||||||
)
|
)
|
||||||
|
|
||||||
meta: dict[str, Any] = {
|
meta: dict[str, Any] = {
|
||||||
@@ -217,26 +310,31 @@ def run_scheduled(
|
|||||||
"host": host,
|
"host": host,
|
||||||
"type": "scheduled",
|
"type": "scheduled",
|
||||||
"label": None,
|
"label": None,
|
||||||
|
"verbose_output": bool(verbose_output),
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"started_at": format_iso_z(ts),
|
"started_at": format_iso_z(ts),
|
||||||
"ended_at": None,
|
"ended_at": None,
|
||||||
"duration_seconds": None,
|
"duration_seconds": None,
|
||||||
"base": _base_meta_from_path(base_dir, link_dest),
|
"base": _base_meta_from_path(base_dir, link_dest),
|
||||||
"rsync": {"exit_code": None, "command": cmd, "stats": {}},
|
"rsync": {"exit_code": None, "command": cmd, "stats": {}},
|
||||||
# Keep existing fields for future expansion / compatibility with current structure.
|
|
||||||
"overrides": {"includes": [], "excludes": [], "base": None},
|
"overrides": {"includes": [], "excludes": [], "base": None},
|
||||||
}
|
}
|
||||||
|
|
||||||
log_path.touch(exist_ok=True)
|
log_path.touch(exist_ok=True)
|
||||||
write_yaml_atomic(meta_path, meta)
|
write_yaml_atomic(meta_path, meta)
|
||||||
|
|
||||||
result = run_rsync(cmd, log_path=log_path, timeout_seconds=timeout_seconds)
|
result = run_rsync(cmd, log_path=log_path, timeout_seconds=timeout_seconds, cancel_check=cancel_check)
|
||||||
|
|
||||||
end_ts = utc_now()
|
end_ts = utc_now()
|
||||||
meta["ended_at"] = format_iso_z(end_ts)
|
meta["ended_at"] = format_iso_z(end_ts)
|
||||||
meta["duration_seconds"] = int((end_ts - ts).total_seconds())
|
meta["duration_seconds"] = int((end_ts - ts).total_seconds())
|
||||||
meta["rsync"]["exit_code"] = result.exit_code
|
meta["rsync"]["exit_code"] = result.exit_code
|
||||||
meta["status"] = "success" if result.exit_code == 0 else "failed"
|
meta["status"] = "cancelled" if result.cancelled else ("success" if result.exit_code == 0 else "failed")
|
||||||
|
meta["stats"] = _collect_run_stats(
|
||||||
|
log_path=log_path,
|
||||||
|
backup_root=Path(backup_root),
|
||||||
|
duration_seconds=meta["duration_seconds"],
|
||||||
|
)
|
||||||
write_yaml_atomic(meta_path, meta)
|
write_yaml_atomic(meta_path, meta)
|
||||||
|
|
||||||
if not log_path.exists():
|
if not log_path.exists():
|
||||||
@@ -252,17 +350,37 @@ def run_scheduled(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.exit_code != 0:
|
if result.exit_code != 0:
|
||||||
|
log_tail = _read_log_tail(log_path)
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
"host": host,
|
"host": host,
|
||||||
"snapshot": str(incomplete_dir),
|
"snapshot": str(incomplete_dir),
|
||||||
"status": meta["status"],
|
"status": meta["status"],
|
||||||
"rsync": {"exit_code": result.exit_code},
|
"cancelled": result.cancelled,
|
||||||
|
"log": str(log_path),
|
||||||
|
"verbose_output": bool(verbose_output),
|
||||||
|
"ssh_credential": cfg.get("ssh_credential"),
|
||||||
|
"stats": meta["stats"],
|
||||||
|
"rsync": {
|
||||||
|
"exit_code": result.exit_code,
|
||||||
|
"command": result.command,
|
||||||
|
"log_tail": log_tail,
|
||||||
|
},
|
||||||
|
"failure": classify_rsync_failure(result.exit_code, log_tail),
|
||||||
}
|
}
|
||||||
|
|
||||||
final_dir = dirs.scheduled / snap_name
|
final_dir = dirs.scheduled / snap_name
|
||||||
incomplete_dir.rename(final_dir)
|
incomplete_dir.rename(final_dir)
|
||||||
|
final_log_path = final_dir / "meta" / "rsync.log"
|
||||||
|
final_meta_path = final_dir / "meta" / "meta.yaml"
|
||||||
|
meta["stats"] = _collect_run_stats(
|
||||||
|
log_path=final_log_path,
|
||||||
|
backup_root=Path(backup_root),
|
||||||
|
duration_seconds=meta["duration_seconds"],
|
||||||
|
snapshot_dir=final_dir / "data",
|
||||||
|
)
|
||||||
|
write_yaml_atomic(final_meta_path, meta)
|
||||||
|
|
||||||
prune_result = None
|
prune_result = None
|
||||||
if prune:
|
if prune:
|
||||||
@@ -285,6 +403,10 @@ def run_scheduled(
|
|||||||
"host": host,
|
"host": host,
|
||||||
"snapshot": str(final_dir),
|
"snapshot": str(final_dir),
|
||||||
"base": str(base_dir) if base_dir else None,
|
"base": str(base_dir) if base_dir else None,
|
||||||
|
"log": str(final_log_path),
|
||||||
"rsync": {"exit_code": result.exit_code},
|
"rsync": {"exit_code": result.exit_code},
|
||||||
|
"verbose_output": bool(verbose_output),
|
||||||
|
"duration_seconds": meta["duration_seconds"],
|
||||||
|
"stats": meta["stats"],
|
||||||
"prune": prune_result,
|
"prune": prune_result,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from ..errors import ConfigError, ValidationError
|
|
||||||
from ..validate import validate_dict
|
|
||||||
from .schemas import GLOBAL_SCHEMA, HOST_SCHEMA
|
|
||||||
|
|
||||||
|
|
||||||
def load_yaml_file(path: Path) -> dict[str, Any]:
|
|
||||||
if not path.exists():
|
|
||||||
raise ConfigError(f"Missing config file: {path}")
|
|
||||||
try:
|
|
||||||
raw = path.read_text(encoding="utf-8")
|
|
||||||
except OSError as e:
|
|
||||||
raise ConfigError(f"Cannot read config file: {path}: {e}") from e
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = yaml.safe_load(raw)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ConfigError(f"Invalid YAML in {path}: {e}") from e
|
|
||||||
|
|
||||||
if data is None:
|
|
||||||
data = {}
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ConfigError(f"Config root must be a mapping in {path}")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def load_global_config(path: Path) -> dict[str, Any]:
|
|
||||||
data = load_yaml_file(path)
|
|
||||||
try:
|
|
||||||
return validate_dict(data, GLOBAL_SCHEMA, path="global")
|
|
||||||
except ValidationError as e:
|
|
||||||
raise ConfigError(f"Invalid global config at {path}: {format_validation_error(e)}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def load_host_config(path: Path) -> dict[str, Any]:
|
|
||||||
data = load_yaml_file(path)
|
|
||||||
try:
|
|
||||||
return validate_dict(data, HOST_SCHEMA, path="host")
|
|
||||||
except ValidationError as e:
|
|
||||||
raise ConfigError(f"Invalid host config at {path}: {format_validation_error(e)}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def format_validation_error(err: ValidationError) -> str:
|
|
||||||
if err.path:
|
|
||||||
return f"{err.path}: {err}"
|
|
||||||
return str(err)
|
|
||||||
|
|
||||||
@@ -83,7 +83,6 @@ OUTPUT_SCHEMA = Schema(
|
|||||||
GLOBAL_SCHEMA = Schema(
|
GLOBAL_SCHEMA = Schema(
|
||||||
fields={
|
fields={
|
||||||
"backup_root": FieldSpec(str, required=True),
|
"backup_root": FieldSpec(str, required=True),
|
||||||
"pobsync_home": FieldSpec(str, required=False, default="/opt/pobsync"),
|
|
||||||
"ssh": FieldSpec(dict, required=False, schema=SSH_SCHEMA),
|
"ssh": FieldSpec(dict, required=False, schema=SSH_SCHEMA),
|
||||||
"rsync": FieldSpec(dict, required=False, schema=RSYNC_SCHEMA),
|
"rsync": FieldSpec(dict, required=False, schema=RSYNC_SCHEMA),
|
||||||
"defaults": FieldSpec(dict, required=False, schema=DEFAULTS_SCHEMA),
|
"defaults": FieldSpec(dict, required=False, schema=DEFAULTS_SCHEMA),
|
||||||
@@ -95,7 +94,6 @@ GLOBAL_SCHEMA = Schema(
|
|||||||
),
|
),
|
||||||
"logging": FieldSpec(dict, required=False, schema=LOGGING_SCHEMA),
|
"logging": FieldSpec(dict, required=False, schema=LOGGING_SCHEMA),
|
||||||
"output": FieldSpec(dict, required=False, schema=OUTPUT_SCHEMA),
|
"output": FieldSpec(dict, required=False, schema=OUTPUT_SCHEMA),
|
||||||
# Used by `init-host` as a convenience default
|
|
||||||
"retention_defaults": FieldSpec(
|
"retention_defaults": FieldSpec(
|
||||||
dict,
|
dict,
|
||||||
required=False,
|
required=False,
|
||||||
@@ -131,4 +129,3 @@ HOST_SCHEMA = Schema(
|
|||||||
},
|
},
|
||||||
allow_unknown=False,
|
allow_unknown=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from .load import load_global_config, load_host_config
|
|
||||||
from .merge import build_effective_config
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigSource(Protocol):
|
class ConfigSource(Protocol):
|
||||||
def effective_config_for_host(self, host: str) -> dict[str, Any]:
|
def effective_config_for_host(self, host: str) -> dict[str, Any]:
|
||||||
"""Return the fully merged effective config for a host."""
|
"""Return the fully merged effective config for a host."""
|
||||||
|
|
||||||
|
|
||||||
class FileConfigSource:
|
|
||||||
def __init__(self, prefix: Path) -> None:
|
|
||||||
self.prefix = prefix
|
|
||||||
|
|
||||||
def effective_config_for_host(self, host: str) -> dict[str, Any]:
|
|
||||||
global_cfg = load_global_config(self.prefix / "config" / "global.yaml")
|
|
||||||
host_cfg = load_host_config(self.prefix / "config" / "hosts" / f"{host}.yaml")
|
|
||||||
return build_effective_config(global_cfg, host_cfg)
|
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ from pathlib import Path
|
|||||||
class PobsyncPaths:
|
class PobsyncPaths:
|
||||||
home: Path # usually /opt/pobsync
|
home: Path # usually /opt/pobsync
|
||||||
|
|
||||||
@property
|
|
||||||
def config_dir(self) -> Path:
|
|
||||||
return self.home / "config"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hosts_dir(self) -> Path:
|
|
||||||
return self.config_dir / "hosts"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state_dir(self) -> Path:
|
def state_dir(self) -> Path:
|
||||||
return self.home / "state"
|
return self.home / "state"
|
||||||
@@ -28,11 +20,6 @@ class PobsyncPaths:
|
|||||||
def logs_dir(self) -> Path:
|
def logs_dir(self) -> Path:
|
||||||
return self.home / "logs"
|
return self.home / "logs"
|
||||||
|
|
||||||
@property
|
|
||||||
def global_config_path(self) -> Path:
|
|
||||||
return self.config_dir / "global.yaml"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def central_log_path(self) -> Path:
|
def central_log_path(self) -> Path:
|
||||||
return self.logs_dir / "pobsync.log"
|
return self.logs_dir / "pobsync.log"
|
||||||
|
|
||||||
|
|||||||
@@ -123,3 +123,50 @@ def build_retention_plan(
|
|||||||
|
|
||||||
return RetentionResult(keep=keep, reasons=reasons)
|
return RetentionResult(keep=keep, reasons=reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_base_protection(
|
||||||
|
*,
|
||||||
|
snapshots: Iterable[Snapshot],
|
||||||
|
keep: Set[str],
|
||||||
|
reasons: Dict[str, List[str]],
|
||||||
|
) -> Tuple[Set[str], Dict[str, List[str]]]:
|
||||||
|
"""
|
||||||
|
If a kept snapshot has a base (kind+dirname), also keep that base snapshot.
|
||||||
|
|
||||||
|
Hardlink snapshots remain readable without this, but keeping bases can make
|
||||||
|
future base selection and chain inspection easier.
|
||||||
|
"""
|
||||||
|
snapshot_list = list(snapshots)
|
||||||
|
index: Dict[Tuple[str, str], Snapshot] = {(snapshot.kind, snapshot.dirname): snapshot for snapshot in snapshot_list}
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for child_dirname in list(keep):
|
||||||
|
child = _find_snapshot_by_dirname(snapshot_list, child_dirname)
|
||||||
|
if child is None or not isinstance(child.base, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_kind = child.base.get("kind")
|
||||||
|
base_dirname = child.base.get("dirname")
|
||||||
|
if not isinstance(base_kind, str) or not isinstance(base_dirname, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_snapshot = index.get((base_kind, base_dirname))
|
||||||
|
if base_snapshot is None or base_dirname in keep:
|
||||||
|
continue
|
||||||
|
|
||||||
|
keep.add(base_dirname)
|
||||||
|
reasons.setdefault(base_dirname, []).append(f"base-of:{child_dirname}")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return keep, reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _find_snapshot_by_dirname(snapshots: Iterable[Snapshot], dirname: str) -> Snapshot | None:
|
||||||
|
for kind in ("scheduled", "manual"):
|
||||||
|
for snapshot in snapshots:
|
||||||
|
if snapshot.kind == kind and snapshot.dirname == dirname:
|
||||||
|
return snapshot
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_VERBOSE_OUTPUT_ARGS = ["--itemize-changes", "--info=flist2,progress2,stats2"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RsyncResult:
|
class RsyncResult:
|
||||||
exit_code: int
|
exit_code: int
|
||||||
command: list[str]
|
command: list[str]
|
||||||
|
cancelled: bool = False
|
||||||
|
|
||||||
|
|
||||||
def build_ssh_command(ssh_cfg: dict) -> list[str]:
|
def build_ssh_command(ssh_cfg: dict) -> list[str]:
|
||||||
@@ -36,10 +43,14 @@ def build_rsync_command(
|
|||||||
bwlimit_kbps: int,
|
bwlimit_kbps: int,
|
||||||
extra_excludes: Sequence[str],
|
extra_excludes: Sequence[str],
|
||||||
extra_includes: Sequence[str],
|
extra_includes: Sequence[str],
|
||||||
|
verbose_output: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
cmd: list[str] = [rsync_binary]
|
cmd: list[str] = [rsync_binary]
|
||||||
|
|
||||||
cmd.extend(list(rsync_args))
|
cmd.extend(list(rsync_args))
|
||||||
|
_append_stats_arg(cmd)
|
||||||
|
if dry_run or verbose_output:
|
||||||
|
_append_default_verbose_output_args(cmd)
|
||||||
|
|
||||||
# includes/excludes: keep it simple for now:
|
# includes/excludes: keep it simple for now:
|
||||||
# - if includes are provided, user is responsible for correct rsync include logic.
|
# - if includes are provided, user is responsible for correct rsync include logic.
|
||||||
@@ -66,7 +77,12 @@ def build_rsync_command(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def run_rsync(command: list[str], log_path: Path, timeout_seconds: int) -> RsyncResult:
|
def run_rsync(
|
||||||
|
command: list[str],
|
||||||
|
log_path: Path,
|
||||||
|
timeout_seconds: int,
|
||||||
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
) -> RsyncResult:
|
||||||
"""
|
"""
|
||||||
Run rsync and always write stdout/stderr to log_path.
|
Run rsync and always write stdout/stderr to log_path.
|
||||||
|
|
||||||
@@ -77,17 +93,54 @@ def run_rsync(command: list[str], log_path: Path, timeout_seconds: int) -> Rsync
|
|||||||
# Ensure the file exists early.
|
# Ensure the file exists early.
|
||||||
log_path.touch(exist_ok=True)
|
log_path.touch(exist_ok=True)
|
||||||
|
|
||||||
try:
|
|
||||||
with log_path.open("ab") as f:
|
|
||||||
p = subprocess.run(
|
|
||||||
command,
|
|
||||||
stdout=f,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
timeout=timeout_seconds if timeout_seconds > 0 else None,
|
|
||||||
)
|
|
||||||
return RsyncResult(exit_code=p.returncode, command=command)
|
|
||||||
except subprocess.TimeoutExpired as e:
|
|
||||||
# Log timeout info and return a non-zero exit code.
|
|
||||||
with log_path.open("ab") as f:
|
with log_path.open("ab") as f:
|
||||||
|
process = subprocess.Popen(command, stdout=f, stderr=subprocess.STDOUT, start_new_session=True)
|
||||||
|
started = time.monotonic()
|
||||||
|
while True:
|
||||||
|
exit_code = process.poll()
|
||||||
|
if exit_code is not None:
|
||||||
|
return RsyncResult(exit_code=exit_code, command=command)
|
||||||
|
|
||||||
|
if cancel_check is not None and cancel_check():
|
||||||
|
_terminate_process_group(process)
|
||||||
|
f.write(b"\n[pobsync] rsync cancelled\n")
|
||||||
|
return RsyncResult(exit_code=130, command=command, cancelled=True)
|
||||||
|
|
||||||
|
if timeout_seconds > 0 and time.monotonic() - started >= timeout_seconds:
|
||||||
|
_terminate_process_group(process)
|
||||||
f.write(b"\n[pobsync] rsync timed out\n")
|
f.write(b"\n[pobsync] rsync timed out\n")
|
||||||
return RsyncResult(exit_code=124, command=command)
|
return RsyncResult(exit_code=124, command=command)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_process_group(process: subprocess.Popen) -> None:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
process.wait(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_default_verbose_output_args(command: list[str]) -> None:
|
||||||
|
if not _has_itemize_arg(command):
|
||||||
|
command.append("--itemize-changes")
|
||||||
|
if not any(arg.startswith("--info=") for arg in command):
|
||||||
|
command.append("--info=flist2,progress2,stats2")
|
||||||
|
|
||||||
|
|
||||||
|
def _append_stats_arg(command: list[str]) -> None:
|
||||||
|
if "--stats" not in command:
|
||||||
|
command.append("--stats")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_itemize_arg(command: list[str]) -> bool:
|
||||||
|
for arg in command:
|
||||||
|
if arg == "--itemize-changes":
|
||||||
|
return True
|
||||||
|
if arg.startswith("-") and not arg.startswith("--") and "i" in arg:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|||||||
238
src/pobsync/run_stats.py
Normal file
238
src/pobsync/run_stats.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_COUNT_KEYS = {
|
||||||
|
"Number of files": "files_total",
|
||||||
|
"Number of created files": "files_created",
|
||||||
|
"Number of deleted files": "files_deleted",
|
||||||
|
"Number of regular files transferred": "files_transferred",
|
||||||
|
}
|
||||||
|
|
||||||
|
_BYTE_KEYS = {
|
||||||
|
"Total file size": "total_file_size_bytes",
|
||||||
|
"Total transferred file size": "total_transferred_file_size_bytes",
|
||||||
|
"Literal data": "literal_data_bytes",
|
||||||
|
"Matched data": "matched_data_bytes",
|
||||||
|
"File list size": "file_list_size_bytes",
|
||||||
|
"Total bytes sent": "bytes_sent",
|
||||||
|
"Total bytes received": "bytes_received",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SIZE_UNITS = {
|
||||||
|
"": 1,
|
||||||
|
"b": 1,
|
||||||
|
"k": 1000,
|
||||||
|
"kb": 1000,
|
||||||
|
"m": 1000**2,
|
||||||
|
"mb": 1000**2,
|
||||||
|
"g": 1000**3,
|
||||||
|
"gb": 1000**3,
|
||||||
|
"t": 1000**4,
|
||||||
|
"tb": 1000**4,
|
||||||
|
"p": 1000**5,
|
||||||
|
"pb": 1000**5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rsync_stats(text: str) -> dict[str, Any]:
|
||||||
|
stats: dict[str, Any] = {}
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_parse_colon_stat(line, stats)
|
||||||
|
_parse_sent_received(line, stats)
|
||||||
|
_parse_total_size_speedup(line, stats)
|
||||||
|
|
||||||
|
_add_derived_stats(stats)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def read_rsync_stats(log_path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
return parse_rsync_stats(text)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_storage_stats(*, backup_root: Path, snapshot_dir: Path | None = None) -> dict[str, Any]:
|
||||||
|
stats: dict[str, Any] = {
|
||||||
|
"backup_root": str(backup_root),
|
||||||
|
}
|
||||||
|
capacity = filesystem_capacity(backup_root)
|
||||||
|
if capacity:
|
||||||
|
stats["capacity"] = capacity
|
||||||
|
|
||||||
|
if snapshot_dir is not None:
|
||||||
|
snapshot_usage = tree_usage(snapshot_dir)
|
||||||
|
if snapshot_usage:
|
||||||
|
stats["snapshot"] = {
|
||||||
|
"path": str(snapshot_dir),
|
||||||
|
**snapshot_usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def filesystem_capacity(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
statvfs = os.statvfs(path)
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
total = int(statvfs.f_frsize * statvfs.f_blocks)
|
||||||
|
available = int(statvfs.f_frsize * statvfs.f_bavail)
|
||||||
|
free = int(statvfs.f_frsize * statvfs.f_bfree)
|
||||||
|
used = max(total - free, 0)
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"total_bytes": total,
|
||||||
|
"used_bytes": used,
|
||||||
|
"free_bytes": free,
|
||||||
|
"available_bytes": available,
|
||||||
|
"used_percent": round((used / total) * 100, 2) if total else 0.0,
|
||||||
|
"device": getattr(stat, "st_dev", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tree_usage(path: Path) -> dict[str, Any]:
|
||||||
|
apparent_size = 0
|
||||||
|
allocated_size = 0
|
||||||
|
files = 0
|
||||||
|
directories = 0
|
||||||
|
hardlinked_files = 0
|
||||||
|
hardlinked_apparent_size = 0
|
||||||
|
seen_allocated_inodes: set[tuple[int, int]] = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
root_stat = path.lstat()
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
files = 1
|
||||||
|
apparent_size = root_stat.st_size
|
||||||
|
allocated_size = int(getattr(root_stat, "st_blocks", 0) * 512)
|
||||||
|
if root_stat.st_nlink > 1:
|
||||||
|
hardlinked_files = 1
|
||||||
|
hardlinked_apparent_size = root_stat.st_size
|
||||||
|
else:
|
||||||
|
for current_root, dirnames, filenames in path.walk():
|
||||||
|
directories += len(dirnames)
|
||||||
|
for filename in filenames:
|
||||||
|
file_path = current_root / filename
|
||||||
|
try:
|
||||||
|
file_stat = file_path.lstat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
files += 1
|
||||||
|
apparent_size += file_stat.st_size
|
||||||
|
inode_key = (file_stat.st_dev, file_stat.st_ino)
|
||||||
|
if inode_key not in seen_allocated_inodes:
|
||||||
|
allocated_size += int(getattr(file_stat, "st_blocks", 0) * 512)
|
||||||
|
seen_allocated_inodes.add(inode_key)
|
||||||
|
if file_stat.st_nlink > 1:
|
||||||
|
hardlinked_files += 1
|
||||||
|
hardlinked_apparent_size += file_stat.st_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"apparent_size_bytes": int(apparent_size),
|
||||||
|
"allocated_size_bytes": int(allocated_size),
|
||||||
|
"files": files,
|
||||||
|
"directories": directories,
|
||||||
|
"hardlinked_files": hardlinked_files,
|
||||||
|
"hardlinked_apparent_size_bytes": int(hardlinked_apparent_size),
|
||||||
|
"hardlink_apparent_ratio": round(hardlinked_apparent_size / apparent_size, 4) if apparent_size else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_colon_stat(line: str, stats: dict[str, Any]) -> None:
|
||||||
|
if ":" not in line:
|
||||||
|
return
|
||||||
|
label, value = line.split(":", 1)
|
||||||
|
label = label.strip()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if label in _COUNT_KEYS:
|
||||||
|
parsed = _parse_int_prefix(value)
|
||||||
|
if parsed is not None:
|
||||||
|
stats[_COUNT_KEYS[label]] = parsed
|
||||||
|
elif label in _BYTE_KEYS:
|
||||||
|
parsed = _parse_byte_value(value)
|
||||||
|
if parsed is not None:
|
||||||
|
stats[_BYTE_KEYS[label]] = parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sent_received(line: str, stats: dict[str, Any]) -> None:
|
||||||
|
match = re.search(
|
||||||
|
r"sent\s+(?P<sent>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+bytes\s+received\s+"
|
||||||
|
r"(?P<received>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+bytes\s+"
|
||||||
|
r"(?P<rate>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+bytes/sec",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
sent = _parse_byte_value(match.group("sent"))
|
||||||
|
received = _parse_byte_value(match.group("received"))
|
||||||
|
rate = _parse_byte_value(match.group("rate"))
|
||||||
|
if sent is not None:
|
||||||
|
stats["bytes_sent"] = sent
|
||||||
|
if received is not None:
|
||||||
|
stats["bytes_received"] = received
|
||||||
|
if rate is not None:
|
||||||
|
stats["bytes_per_second"] = rate
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_total_size_speedup(line: str, stats: dict[str, Any]) -> None:
|
||||||
|
match = re.search(
|
||||||
|
r"total size is\s+(?P<size>[\d,]+(?:\.\d+)?\s*[A-Za-z]*)\s+speedup is\s+(?P<speedup>[\d,]+(?:\.\d+)?)",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
total_size = _parse_byte_value(match.group("size"))
|
||||||
|
if total_size is not None:
|
||||||
|
stats["total_file_size_bytes"] = total_size
|
||||||
|
stats["speedup"] = float(match.group("speedup").replace(",", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_derived_stats(stats: dict[str, Any]) -> None:
|
||||||
|
sent = stats.get("bytes_sent")
|
||||||
|
received = stats.get("bytes_received")
|
||||||
|
if isinstance(sent, int) and isinstance(received, int):
|
||||||
|
stats["bytes_sent_received"] = sent + received
|
||||||
|
|
||||||
|
literal = stats.get("literal_data_bytes")
|
||||||
|
matched = stats.get("matched_data_bytes")
|
||||||
|
if isinstance(literal, int) and isinstance(matched, int):
|
||||||
|
basis_total = literal + matched
|
||||||
|
stats["link_dest_estimated_savings_bytes"] = matched
|
||||||
|
stats["link_dest_estimated_savings_ratio"] = round(matched / basis_total, 4) if basis_total else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int_prefix(value: str) -> int | None:
|
||||||
|
match = re.match(r"([\d,]+)", value)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return int(match.group(1).replace(",", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_byte_value(value: str) -> int | None:
|
||||||
|
match = re.match(r"([\d,]+(?:\.\d+)?)\s*([A-Za-z]*)", value.strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
number = float(match.group(1).replace(",", ""))
|
||||||
|
unit = match.group(2).lower()
|
||||||
|
multiplier = _SIZE_UNITS.get(unit)
|
||||||
|
if multiplier is None:
|
||||||
|
return int(number)
|
||||||
|
return int(number * multiplier)
|
||||||
@@ -1,8 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
|
from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SshCredential)
|
||||||
|
class SshCredentialAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "key_type", "generated", "has_public_key", "has_known_hosts", "updated_at")
|
||||||
|
readonly_fields = ("created_at", "updated_at", "fingerprint")
|
||||||
|
search_fields = ("name", "notes")
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("name", "key_type", "generated", "key_path", "fingerprint")}),
|
||||||
|
("Key material", {"fields": ("private_key", "public_key", "known_hosts", "notes")}),
|
||||||
|
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(boolean=True, description="Public key")
|
||||||
|
def has_public_key(self, obj: SshCredential) -> bool:
|
||||||
|
return bool(obj.public_key.strip())
|
||||||
|
|
||||||
|
@admin.display(boolean=True, description="Known hosts")
|
||||||
|
def has_known_hosts(self, obj: SshCredential) -> bool:
|
||||||
|
return bool(obj.known_hosts.strip())
|
||||||
|
|
||||||
|
|
||||||
@admin.register(GlobalConfig)
|
@admin.register(GlobalConfig)
|
||||||
@@ -10,8 +34,8 @@ class GlobalConfigAdmin(admin.ModelAdmin):
|
|||||||
list_display = ("name", "backup_root", "ssh_user", "ssh_port", "updated_at")
|
list_display = ("name", "backup_root", "ssh_user", "ssh_port", "updated_at")
|
||||||
readonly_fields = ("created_at", "updated_at")
|
readonly_fields = ("created_at", "updated_at")
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "backup_root", "pobsync_home")}),
|
(None, {"fields": ("name", "backup_root")}),
|
||||||
("SSH", {"fields": ("ssh_user", "ssh_port", "ssh_options")}),
|
("SSH", {"fields": ("default_ssh_credential", "ssh_user", "ssh_port", "ssh_options")}),
|
||||||
(
|
(
|
||||||
"Rsync",
|
"Rsync",
|
||||||
{
|
{
|
||||||
@@ -26,45 +50,147 @@ class GlobalConfigAdmin(admin.ModelAdmin):
|
|||||||
),
|
),
|
||||||
("Defaults", {"fields": ("default_source_root", "default_destination_subdir", "excludes_default")}),
|
("Defaults", {"fields": ("default_source_root", "default_destination_subdir", "excludes_default")}),
|
||||||
("Retention defaults", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
|
("Retention defaults", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
|
||||||
("Legacy JSON", {"fields": ("data",), "classes": ("collapse",)}),
|
|
||||||
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(HostConfig)
|
@admin.register(HostConfig)
|
||||||
class HostConfigAdmin(admin.ModelAdmin):
|
class HostConfigAdmin(admin.ModelAdmin):
|
||||||
list_display = ("host", "address", "enabled", "updated_at")
|
list_display = (
|
||||||
|
"host",
|
||||||
|
"address",
|
||||||
|
"enabled",
|
||||||
|
"schedule_state",
|
||||||
|
"snapshot_count_link",
|
||||||
|
"backup_run_count_link",
|
||||||
|
"latest_run_state",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
list_filter = ("enabled",)
|
list_filter = ("enabled",)
|
||||||
search_fields = ("host", "address")
|
search_fields = ("host", "address")
|
||||||
readonly_fields = ("created_at", "updated_at")
|
readonly_fields = ("created_at", "updated_at")
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("host", "address", "enabled")}),
|
(None, {"fields": ("host", "address", "enabled")}),
|
||||||
("SSH override", {"fields": ("ssh_user", "ssh_port")}),
|
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
|
||||||
("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}),
|
("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}),
|
||||||
("Rsync override", {"fields": ("rsync_extra_args",)}),
|
("Rsync override", {"fields": ("rsync_extra_args",)}),
|
||||||
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
|
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
|
||||||
("Legacy JSON", {"fields": ("config",), "classes": ("collapse",)}),
|
("Runtime state", {"fields": ("config",), "classes": ("collapse",)}),
|
||||||
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).annotate(
|
||||||
|
snapshot_count=Count("snapshots", distinct=True),
|
||||||
|
backup_run_count=Count("runs", distinct=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Schedule")
|
||||||
|
def schedule_state(self, obj: HostConfig) -> str:
|
||||||
|
try:
|
||||||
|
schedule = obj.schedule
|
||||||
|
except ScheduleConfig.DoesNotExist:
|
||||||
|
return "none"
|
||||||
|
if not schedule.enabled:
|
||||||
|
return "disabled"
|
||||||
|
return schedule.cron_expr
|
||||||
|
|
||||||
|
@admin.display(description="Snapshots", ordering="snapshot_count")
|
||||||
|
def snapshot_count_link(self, obj: HostConfig) -> str:
|
||||||
|
count = getattr(obj, "snapshot_count", None)
|
||||||
|
if count is None:
|
||||||
|
count = obj.snapshots.count()
|
||||||
|
url = _admin_changelist_url("pobsync_backend", "snapshotrecord", {"host__id__exact": obj.pk})
|
||||||
|
return format_html('<a href="{}">{}</a>', url, count)
|
||||||
|
|
||||||
|
@admin.display(description="Runs", ordering="backup_run_count")
|
||||||
|
def backup_run_count_link(self, obj: HostConfig) -> str:
|
||||||
|
count = getattr(obj, "backup_run_count", None)
|
||||||
|
if count is None:
|
||||||
|
count = obj.runs.count()
|
||||||
|
url = _admin_changelist_url("pobsync_backend", "backuprun", {"host__id__exact": obj.pk})
|
||||||
|
return format_html('<a href="{}">{}</a>', url, count)
|
||||||
|
|
||||||
|
@admin.display(description="Latest run")
|
||||||
|
def latest_run_state(self, obj: HostConfig) -> str:
|
||||||
|
latest = obj.runs.order_by("-created_at").first()
|
||||||
|
if latest is None:
|
||||||
|
return "none"
|
||||||
|
return f"{latest.status} {latest.started_at:%Y-%m-%d %H:%M}" if latest.started_at else latest.status
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BackupRun)
|
@admin.register(BackupRun)
|
||||||
class BackupRunAdmin(admin.ModelAdmin):
|
class BackupRunAdmin(admin.ModelAdmin):
|
||||||
list_display = ("host", "run_type", "status", "started_at", "ended_at", "snapshot_path")
|
list_display = ("host", "run_type", "status", "started_at", "ended_at", "snapshot_link")
|
||||||
list_filter = ("run_type", "status", "started_at")
|
list_filter = ("run_type", "status", "started_at")
|
||||||
search_fields = ("host__host", "snapshot_path")
|
search_fields = ("host__host", "snapshot_path", "snapshot__dirname", "snapshot__path")
|
||||||
|
autocomplete_fields = ("snapshot",)
|
||||||
|
list_select_related = ("host", "snapshot")
|
||||||
|
date_hierarchy = "started_at"
|
||||||
|
|
||||||
|
@admin.display(description="Snapshot", ordering="snapshot__dirname")
|
||||||
|
def snapshot_link(self, obj: BackupRun) -> str:
|
||||||
|
if obj.snapshot is None:
|
||||||
|
return ""
|
||||||
|
url = reverse("admin:pobsync_backend_snapshotrecord_change", args=[obj.snapshot.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.snapshot.dirname)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SnapshotRecord)
|
@admin.register(SnapshotRecord)
|
||||||
class SnapshotRecordAdmin(admin.ModelAdmin):
|
class SnapshotRecordAdmin(admin.ModelAdmin):
|
||||||
list_display = ("host", "kind", "dirname", "status", "started_at", "discovered_at")
|
list_display = ("host", "kind", "dirname", "status", "base_link", "backup_run_count_link", "started_at", "discovered_at")
|
||||||
list_filter = ("kind", "status", "started_at", "discovered_at")
|
list_filter = ("kind", "status", "base_kind", "started_at", "discovered_at")
|
||||||
search_fields = ("host__host", "dirname", "path")
|
search_fields = (
|
||||||
|
"host__host",
|
||||||
|
"dirname",
|
||||||
|
"path",
|
||||||
|
"base__dirname",
|
||||||
|
"base_path",
|
||||||
|
"base_snapshot_id",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("base",)
|
||||||
|
list_select_related = ("host", "base")
|
||||||
readonly_fields = ("discovered_at",)
|
readonly_fields = ("discovered_at",)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).annotate(backup_run_count=Count("backup_runs"))
|
||||||
|
|
||||||
|
@admin.display(description="Base", ordering="base__dirname")
|
||||||
|
def base_link(self, obj: SnapshotRecord) -> str:
|
||||||
|
if obj.base is not None:
|
||||||
|
url = reverse("admin:pobsync_backend_snapshotrecord_change", args=[obj.base.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.base.dirname)
|
||||||
|
if obj.base_dirname:
|
||||||
|
return obj.base_dirname
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@admin.display(description="Runs", ordering="backup_run_count")
|
||||||
|
def backup_run_count_link(self, obj: SnapshotRecord) -> str:
|
||||||
|
count = getattr(obj, "backup_run_count", None)
|
||||||
|
if count is None:
|
||||||
|
count = obj.backup_runs.count()
|
||||||
|
url = _admin_changelist_url("pobsync_backend", "backuprun", {"snapshot__id__exact": obj.pk})
|
||||||
|
return format_html('<a href="{}">{}</a>', url, count)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PurgedSnapshot)
|
||||||
|
class PurgedSnapshotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("host_name", "kind", "dirname", "action", "reason", "triggered_by", "purged_at")
|
||||||
|
list_filter = ("action", "kind", "purged_at")
|
||||||
|
search_fields = ("host_name", "dirname", "path", "reason", "triggered_by")
|
||||||
|
list_select_related = ("host",)
|
||||||
|
readonly_fields = ("purged_at",)
|
||||||
|
date_hierarchy = "purged_at"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ScheduleConfig)
|
@admin.register(ScheduleConfig)
|
||||||
class ScheduleConfigAdmin(admin.ModelAdmin):
|
class ScheduleConfigAdmin(admin.ModelAdmin):
|
||||||
list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at")
|
list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at")
|
||||||
list_filter = ("enabled", "prune", "last_status")
|
list_filter = ("enabled", "prune", "last_status")
|
||||||
search_fields = ("host__host", "cron_expr")
|
search_fields = ("host__host", "cron_expr")
|
||||||
|
list_select_related = ("host",)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_changelist_url(app_label: str, model_name: str, params: dict[str, object]) -> str:
|
||||||
|
base_url = reverse(f"admin:{app_label}_{model_name}_changelist")
|
||||||
|
return f"{base_url}?{urlencode(params)}"
|
||||||
|
|||||||
194
src/pobsync_backend/api.py
Normal file
194
src/pobsync_backend/api.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
|
from django.db import connection
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def api_index(request) -> JsonResponse:
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"endpoints": {
|
||||||
|
"status": request.build_absolute_uri("/api/status/"),
|
||||||
|
"hosts": request.build_absolute_uri("/api/hosts/"),
|
||||||
|
"snapshots": request.build_absolute_uri("/api/snapshots/"),
|
||||||
|
"runs": request.build_absolute_uri("/api/runs/"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def status(request) -> JsonResponse:
|
||||||
|
latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first()
|
||||||
|
latest_schedule = ScheduleConfig.objects.select_related("host").order_by("-last_started_at", "-updated_at").first()
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"generated_at": timezone.now().isoformat(),
|
||||||
|
"database": {
|
||||||
|
"vendor": connection.vendor,
|
||||||
|
"engine": connection.settings_dict["ENGINE"],
|
||||||
|
},
|
||||||
|
"counts": {
|
||||||
|
"hosts": HostConfig.objects.count(),
|
||||||
|
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
|
||||||
|
"schedules": ScheduleConfig.objects.count(),
|
||||||
|
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
|
||||||
|
"snapshots": SnapshotRecord.objects.count(),
|
||||||
|
"runs": BackupRun.objects.count(),
|
||||||
|
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
|
||||||
|
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
|
||||||
|
},
|
||||||
|
"latest_run": None if latest_run is None else _run_payload(latest_run),
|
||||||
|
"latest_schedule": None if latest_schedule is None else _schedule_payload(latest_schedule),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def hosts(request) -> JsonResponse:
|
||||||
|
host_qs = (
|
||||||
|
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
|
||||||
|
.select_related("schedule")
|
||||||
|
.order_by("host")
|
||||||
|
)
|
||||||
|
return JsonResponse({"ok": True, "hosts": [_host_payload(host) for host in host_qs]})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def snapshots(request) -> JsonResponse:
|
||||||
|
snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname")
|
||||||
|
host_filter = request.GET.get("host")
|
||||||
|
kind_filter = request.GET.get("kind")
|
||||||
|
if host_filter:
|
||||||
|
snapshot_qs = snapshot_qs.filter(host__host=host_filter)
|
||||||
|
if kind_filter:
|
||||||
|
snapshot_qs = snapshot_qs.filter(kind=kind_filter)
|
||||||
|
limit = _limit_from_request(request)
|
||||||
|
return JsonResponse({"ok": True, "snapshots": [_snapshot_payload(snapshot) for snapshot in snapshot_qs[:limit]]})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def runs(request) -> JsonResponse:
|
||||||
|
run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")
|
||||||
|
host_filter = request.GET.get("host")
|
||||||
|
status_filter = request.GET.get("status")
|
||||||
|
if host_filter:
|
||||||
|
run_qs = run_qs.filter(host__host=host_filter)
|
||||||
|
if status_filter:
|
||||||
|
run_qs = run_qs.filter(status=status_filter)
|
||||||
|
limit = _limit_from_request(request)
|
||||||
|
return JsonResponse({"ok": True, "runs": [_run_payload(run) for run in run_qs[:limit]]})
|
||||||
|
|
||||||
|
|
||||||
|
def _host_payload(host: HostConfig) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
schedule = host.schedule
|
||||||
|
except ScheduleConfig.DoesNotExist:
|
||||||
|
schedule = None
|
||||||
|
return {
|
||||||
|
"host": host.host,
|
||||||
|
"address": host.address,
|
||||||
|
"enabled": host.enabled,
|
||||||
|
"snapshot_count": host.snapshot_count,
|
||||||
|
"run_count": host.run_count,
|
||||||
|
"retention": {
|
||||||
|
"daily": host.retention_daily,
|
||||||
|
"weekly": host.retention_weekly,
|
||||||
|
"monthly": host.retention_monthly,
|
||||||
|
"yearly": host.retention_yearly,
|
||||||
|
},
|
||||||
|
"schedule": None
|
||||||
|
if schedule is None
|
||||||
|
else _schedule_payload(schedule),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_payload(snapshot: SnapshotRecord) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"host": snapshot.host.host,
|
||||||
|
"kind": snapshot.kind,
|
||||||
|
"dirname": snapshot.dirname,
|
||||||
|
"path": snapshot.path,
|
||||||
|
"status": snapshot.status,
|
||||||
|
"started_at": _iso(snapshot.started_at),
|
||||||
|
"ended_at": _iso(snapshot.ended_at),
|
||||||
|
"discovered_at": _iso(snapshot.discovered_at),
|
||||||
|
"base": _base_payload(snapshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(snapshot: SnapshotRecord) -> dict[str, Any] | None:
|
||||||
|
if snapshot.base is not None:
|
||||||
|
return {
|
||||||
|
"kind": snapshot.base.kind,
|
||||||
|
"dirname": snapshot.base.dirname,
|
||||||
|
"path": snapshot.base.path,
|
||||||
|
"resolved": True,
|
||||||
|
}
|
||||||
|
if snapshot.base_kind and snapshot.base_dirname:
|
||||||
|
return {
|
||||||
|
"kind": snapshot.base_kind,
|
||||||
|
"dirname": snapshot.base_dirname,
|
||||||
|
"path": snapshot.base_path,
|
||||||
|
"snapshot_id": snapshot.base_snapshot_id,
|
||||||
|
"resolved": False,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_payload(run: BackupRun) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": run.pk,
|
||||||
|
"host": run.host.host,
|
||||||
|
"run_type": run.run_type,
|
||||||
|
"status": run.status,
|
||||||
|
"started_at": _iso(run.started_at),
|
||||||
|
"ended_at": _iso(run.ended_at),
|
||||||
|
"snapshot": None
|
||||||
|
if run.snapshot is None
|
||||||
|
else {
|
||||||
|
"kind": run.snapshot.kind,
|
||||||
|
"dirname": run.snapshot.dirname,
|
||||||
|
"path": run.snapshot.path,
|
||||||
|
},
|
||||||
|
"snapshot_path": run.snapshot_path,
|
||||||
|
"base_path": run.base_path,
|
||||||
|
"rsync_exit_code": run.rsync_exit_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_payload(schedule: ScheduleConfig) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"host": schedule.host.host,
|
||||||
|
"cron_expr": schedule.cron_expr,
|
||||||
|
"enabled": schedule.enabled,
|
||||||
|
"prune": schedule.prune,
|
||||||
|
"last_due_key": schedule.last_due_key,
|
||||||
|
"last_status": schedule.last_status,
|
||||||
|
"last_started_at": _iso(schedule.last_started_at),
|
||||||
|
"last_finished_at": _iso(schedule.last_finished_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_from_request(request, *, default: int = 100, maximum: int = 500) -> int:
|
||||||
|
value = request.GET.get("limit", str(default))
|
||||||
|
try:
|
||||||
|
limit = int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return max(1, min(limit, maximum))
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value) -> str | None:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
354
src/pobsync_backend/backup_runner.py
Normal file
354
src/pobsync_backend/backup_runner.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from datetime import timedelta, timezone as datetime_timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from pobsync.commands.run_scheduled import DEFAULT_DRY_RUN_TIMEOUT_SECONDS, classify_rsync_failure, dry_run_log_path, run_scheduled
|
||||||
|
from pobsync_backend.config_source import DjangoConfigSource
|
||||||
|
from pobsync_backend.models import BackupRun, HostConfig
|
||||||
|
from pobsync_backend.retention import run_sql_retention_apply
|
||||||
|
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
|
||||||
|
|
||||||
|
|
||||||
|
def queue_backup_run(
|
||||||
|
*,
|
||||||
|
host: HostConfig,
|
||||||
|
run_type: str = BackupRun.RunType.MANUAL,
|
||||||
|
dry_run: bool = False,
|
||||||
|
verbose_output: bool = False,
|
||||||
|
prune: bool = False,
|
||||||
|
prune_max_delete: int = 10,
|
||||||
|
prune_protect_bases: bool = False,
|
||||||
|
) -> BackupRun:
|
||||||
|
return BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
run_type=run_type,
|
||||||
|
status=BackupRun.Status.QUEUED,
|
||||||
|
result={
|
||||||
|
"requested": {
|
||||||
|
"dry_run": bool(dry_run),
|
||||||
|
"verbose_output": bool(dry_run or verbose_output),
|
||||||
|
"prune": bool(prune),
|
||||||
|
"prune_max_delete": int(prune_max_delete),
|
||||||
|
"prune_protect_bases": bool(prune_protect_bases),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_backup_run(
|
||||||
|
*,
|
||||||
|
run: BackupRun,
|
||||||
|
prefix: Path,
|
||||||
|
dry_run: bool = False,
|
||||||
|
verbose_output: bool = False,
|
||||||
|
prune: bool = False,
|
||||||
|
prune_max_delete: int = 10,
|
||||||
|
prune_protect_bases: bool = False,
|
||||||
|
) -> BackupRun:
|
||||||
|
run.status = BackupRun.Status.RUNNING
|
||||||
|
run.started_at = run.started_at or timezone.now()
|
||||||
|
run.result = _running_result(run=run, dry_run=bool(dry_run))
|
||||||
|
run.save(update_fields=["status", "started_at", "result"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=prefix,
|
||||||
|
host=run.host.host,
|
||||||
|
dry_run=bool(dry_run),
|
||||||
|
prune=False,
|
||||||
|
config_source=DjangoConfigSource(),
|
||||||
|
run_id=run.id,
|
||||||
|
cancel_check=lambda: _run_cancel_requested(run.id),
|
||||||
|
verbose_output=bool(dry_run or verbose_output),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
run.refresh_from_db()
|
||||||
|
run.status = BackupRun.Status.CANCELLED if run.status == BackupRun.Status.CANCELLED else BackupRun.Status.FAILED
|
||||||
|
run.ended_at = timezone.now()
|
||||||
|
run.result = {
|
||||||
|
**(run.result if isinstance(run.result, dict) else {}),
|
||||||
|
"ok": False,
|
||||||
|
"error": str(exc),
|
||||||
|
"type": type(exc).__name__,
|
||||||
|
}
|
||||||
|
run.save(update_fields=["status", "ended_at", "result"])
|
||||||
|
raise
|
||||||
|
|
||||||
|
run.refresh_from_db()
|
||||||
|
if result.get("cancelled") or run.status == BackupRun.Status.CANCELLED:
|
||||||
|
run.status = BackupRun.Status.CANCELLED
|
||||||
|
else:
|
||||||
|
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
|
||||||
|
run.ended_at = timezone.now()
|
||||||
|
run.snapshot_path = str(result.get("snapshot") or "")
|
||||||
|
run.base_path = str(result.get("base") or "")
|
||||||
|
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
|
||||||
|
run.rsync_exit_code = rsync.get("exit_code")
|
||||||
|
run.result = result
|
||||||
|
snapshot_record = None
|
||||||
|
if run.snapshot_path:
|
||||||
|
snapshot_path = Path(run.snapshot_path)
|
||||||
|
try:
|
||||||
|
kind = infer_snapshot_kind(snapshot_path)
|
||||||
|
snapshot_record, _created = upsert_snapshot_record(host=run.host, kind=kind, snapshot_dir=snapshot_path)
|
||||||
|
except ValueError:
|
||||||
|
snapshot_record = None
|
||||||
|
|
||||||
|
if result.get("ok") and not result.get("dry_run") and prune:
|
||||||
|
try:
|
||||||
|
result["prune"] = run_sql_retention_apply(
|
||||||
|
prefix=prefix,
|
||||||
|
host=run.host.host,
|
||||||
|
kind="scheduled",
|
||||||
|
protect_bases=bool(prune_protect_bases),
|
||||||
|
yes=True,
|
||||||
|
max_delete=int(prune_max_delete),
|
||||||
|
action=run.run_type,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
result["prune"] = {"ok": False, "error": str(exc), "type": type(exc).__name__}
|
||||||
|
run.status = BackupRun.Status.WARNING
|
||||||
|
run.result = result
|
||||||
|
run.snapshot = snapshot_record
|
||||||
|
run.save(
|
||||||
|
update_fields=[
|
||||||
|
"status",
|
||||||
|
"ended_at",
|
||||||
|
"snapshot_path",
|
||||||
|
"snapshot",
|
||||||
|
"base_path",
|
||||||
|
"rsync_exit_code",
|
||||||
|
"result",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
run.snapshot = snapshot_record
|
||||||
|
run.result = result
|
||||||
|
run.save(
|
||||||
|
update_fields=[
|
||||||
|
"status",
|
||||||
|
"ended_at",
|
||||||
|
"snapshot_path",
|
||||||
|
"snapshot",
|
||||||
|
"base_path",
|
||||||
|
"rsync_exit_code",
|
||||||
|
"result",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def claim_next_queued_run() -> BackupRun | None:
|
||||||
|
with transaction.atomic():
|
||||||
|
run = (
|
||||||
|
BackupRun.objects.select_related("host")
|
||||||
|
.filter(status=BackupRun.Status.QUEUED, host__enabled=True)
|
||||||
|
.order_by("created_at", "id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if run is None:
|
||||||
|
return None
|
||||||
|
run.status = BackupRun.Status.RUNNING
|
||||||
|
run.started_at = timezone.now()
|
||||||
|
run.save(update_fields=["status", "started_at"])
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_running_runs(*, grace_seconds: int = 300, stale_worker_seconds: int = 24 * 60 * 60) -> int:
|
||||||
|
reconciled = 0
|
||||||
|
for run in BackupRun.objects.select_related("host").filter(status=BackupRun.Status.RUNNING).order_by("started_at", "id"):
|
||||||
|
if _reconcile_running_run(run=run, grace_seconds=grace_seconds, stale_worker_seconds=stale_worker_seconds):
|
||||||
|
reconciled += 1
|
||||||
|
return reconciled
|
||||||
|
|
||||||
|
|
||||||
|
def requested_options(run: BackupRun) -> dict[str, object]:
|
||||||
|
requested = run.result.get("requested") if isinstance(run.result, dict) else None
|
||||||
|
if not isinstance(requested, dict):
|
||||||
|
return {}
|
||||||
|
return requested
|
||||||
|
|
||||||
|
|
||||||
|
def _running_result(*, run: BackupRun, dry_run: bool) -> dict[str, object]:
|
||||||
|
result = dict(run.result) if isinstance(run.result, dict) else {}
|
||||||
|
execution = {
|
||||||
|
**_worker_execution_details(),
|
||||||
|
"started_at": (run.started_at or timezone.now()).isoformat(),
|
||||||
|
"heartbeat_at": timezone.now().isoformat(),
|
||||||
|
}
|
||||||
|
if dry_run:
|
||||||
|
execution["log"] = str(dry_run_log_path(run.host.host, run_id=run.id))
|
||||||
|
result["execution"] = execution
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cancel_requested(run_id: int) -> bool:
|
||||||
|
try:
|
||||||
|
run = BackupRun.objects.only("id", "status", "result").get(id=run_id)
|
||||||
|
except BackupRun.DoesNotExist:
|
||||||
|
return True
|
||||||
|
if run.status == BackupRun.Status.CANCELLED:
|
||||||
|
return True
|
||||||
|
if run.status == BackupRun.Status.RUNNING:
|
||||||
|
_refresh_run_heartbeat(run)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_seconds: int) -> bool:
|
||||||
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
|
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||||
|
stale_worker = _running_worker_timed_out(run=run, stale_worker_seconds=stale_worker_seconds)
|
||||||
|
if not requested.get("dry_run"):
|
||||||
|
if stale_worker:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"host": run.host.host,
|
||||||
|
"failure": {
|
||||||
|
"category": "worker",
|
||||||
|
"message": "The worker heartbeat stopped before the run finished.",
|
||||||
|
"hint": "Check pobsync-worker.service logs before retrying the backup.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
run.status = BackupRun.Status.FAILED
|
||||||
|
run.ended_at = timezone.now()
|
||||||
|
run.result = result
|
||||||
|
run.save(update_fields=["status", "ended_at", "result"])
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
log_path = _execution_log_path(result)
|
||||||
|
log_tail = _read_log_tail(log_path) if log_path is not None else []
|
||||||
|
terminal_log = _terminal_rsync_log(log_tail)
|
||||||
|
timed_out = _running_dry_run_timed_out(run=run, grace_seconds=grace_seconds)
|
||||||
|
if not terminal_log and not timed_out and not stale_worker:
|
||||||
|
return False
|
||||||
|
|
||||||
|
exit_code = _exit_code_from_log(log_tail) or (124 if timed_out or stale_worker else 255)
|
||||||
|
failure = classify_rsync_failure(exit_code, log_tail)
|
||||||
|
if stale_worker and not terminal_log:
|
||||||
|
failure = {
|
||||||
|
"category": "worker",
|
||||||
|
"message": "The worker heartbeat stopped before the dry-run finished.",
|
||||||
|
"hint": "Check pobsync-worker.service logs before retrying the dry-run.",
|
||||||
|
}
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"dry_run": True,
|
||||||
|
"host": run.host.host,
|
||||||
|
"base": result.get("base"),
|
||||||
|
"log": str(log_path) if log_path else "",
|
||||||
|
"failure": failure,
|
||||||
|
"rsync": {
|
||||||
|
**(result.get("rsync") if isinstance(result.get("rsync"), dict) else {}),
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"log_tail": log_tail,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
run.status = BackupRun.Status.FAILED
|
||||||
|
run.ended_at = timezone.now()
|
||||||
|
run.rsync_exit_code = exit_code
|
||||||
|
run.result = result
|
||||||
|
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_execution_details() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"worker_pid": os.getpid(),
|
||||||
|
"worker_host": socket.gethostname(),
|
||||||
|
"claimed_at": timezone.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_run_heartbeat(run: BackupRun, *, interval_seconds: int = 30) -> None:
|
||||||
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
|
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
|
||||||
|
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
|
||||||
|
if heartbeat_at is not None and timezone.now() < heartbeat_at + timedelta(seconds=interval_seconds):
|
||||||
|
return
|
||||||
|
result["execution"] = {
|
||||||
|
**execution,
|
||||||
|
"worker_pid": os.getpid(),
|
||||||
|
"worker_host": socket.gethostname(),
|
||||||
|
"heartbeat_at": timezone.now().isoformat(),
|
||||||
|
}
|
||||||
|
run.result = result
|
||||||
|
run.save(update_fields=["result"])
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_log_path(result: dict[str, object]) -> Path | None:
|
||||||
|
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
|
||||||
|
log = execution.get("log") or result.get("log")
|
||||||
|
if not isinstance(log, str) or not log:
|
||||||
|
return None
|
||||||
|
return Path(log)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_tail(log_path: Path | None, *, max_lines: int = 40) -> list[str]:
|
||||||
|
if log_path is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-max_lines:]
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_rsync_log(log_tail: list[str]) -> bool:
|
||||||
|
return any(line.startswith("rsync error:") for line in log_tail)
|
||||||
|
|
||||||
|
|
||||||
|
def _exit_code_from_log(log_tail: list[str]) -> int | None:
|
||||||
|
for line in reversed(log_tail):
|
||||||
|
if "code 255" in line:
|
||||||
|
return 255
|
||||||
|
if "code 124" in line:
|
||||||
|
return 124
|
||||||
|
if "code 12" in line:
|
||||||
|
return 12
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _running_dry_run_timed_out(*, run: BackupRun, grace_seconds: int) -> bool:
|
||||||
|
if run.started_at is None:
|
||||||
|
return False
|
||||||
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
|
timeout_seconds = result.get("timeout_seconds")
|
||||||
|
if not isinstance(timeout_seconds, int) or timeout_seconds <= 0:
|
||||||
|
timeout_seconds = DEFAULT_DRY_RUN_TIMEOUT_SECONDS
|
||||||
|
return timezone.now() >= run.started_at + timedelta(seconds=timeout_seconds + grace_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _running_worker_timed_out(*, run: BackupRun, stale_worker_seconds: int) -> bool:
|
||||||
|
if stale_worker_seconds <= 0:
|
||||||
|
return False
|
||||||
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
|
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
|
||||||
|
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
|
||||||
|
if heartbeat_at is None:
|
||||||
|
heartbeat_at = run.started_at
|
||||||
|
if heartbeat_at is None:
|
||||||
|
return False
|
||||||
|
return timezone.now() >= heartbeat_at + timedelta(seconds=stale_worker_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_datetime(value: object):
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = timezone.datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if timezone.is_naive(parsed):
|
||||||
|
return timezone.make_aware(parsed, timezone=datetime_timezone.utc)
|
||||||
|
return parsed
|
||||||
201
src/pobsync_backend/config_checks.py
Normal file
201
src/pobsync_backend/config_checks.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .models import GlobalConfig, HostConfig, SshCredential
|
||||||
|
from .self_check import SelfCheck
|
||||||
|
from .ssh_keys import identity_path
|
||||||
|
|
||||||
|
|
||||||
|
CRITICAL_ROOT_EXCLUDES = ("/proc/***", "/sys/***", "/dev/***", "/run/***", "/tmp/***")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_global_config_checks(global_config: GlobalConfig) -> list[SelfCheck]:
|
||||||
|
checks = [
|
||||||
|
_absolute_path_check("Global backup root", global_config.backup_root),
|
||||||
|
_absolute_path_check("Runtime state root", settings.POBSYNC_HOME),
|
||||||
|
_runtime_backup_root_check(global_config),
|
||||||
|
_rsync_binary_check(global_config.rsync_binary),
|
||||||
|
_rsync_recursion_check(
|
||||||
|
"Global rsync recursion",
|
||||||
|
[*list(global_config.rsync_args or []), *list(global_config.rsync_extra_args or [])],
|
||||||
|
),
|
||||||
|
_source_root_check("Global source root", global_config.default_source_root),
|
||||||
|
_root_excludes_check(global_config.default_source_root, list(global_config.excludes_default or [])),
|
||||||
|
_retention_check(
|
||||||
|
"Global retention",
|
||||||
|
global_config.retention_daily,
|
||||||
|
global_config.retention_weekly,
|
||||||
|
global_config.retention_monthly,
|
||||||
|
global_config.retention_yearly,
|
||||||
|
),
|
||||||
|
_ssh_port_check("Global SSH port", global_config.ssh_port),
|
||||||
|
_credential_check("Global SSH credential", global_config.default_ssh_credential),
|
||||||
|
]
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def collect_effective_host_config_checks(host: HostConfig, global_config: GlobalConfig) -> list[SelfCheck]:
|
||||||
|
source_root = host.source_root or global_config.default_source_root
|
||||||
|
ssh_user = host.ssh_user or global_config.ssh_user
|
||||||
|
ssh_port = host.ssh_port or global_config.ssh_port
|
||||||
|
credential = host.ssh_credential or global_config.default_ssh_credential
|
||||||
|
if host.excludes_replace is not None:
|
||||||
|
excludes = list(host.excludes_replace)
|
||||||
|
else:
|
||||||
|
excludes = [*list(global_config.excludes_default or []), *list(host.excludes_add or [])]
|
||||||
|
rsync_args = [
|
||||||
|
*list(global_config.rsync_args or []),
|
||||||
|
*list(global_config.rsync_extra_args or []),
|
||||||
|
*list(host.rsync_extra_args or []),
|
||||||
|
]
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
_source_root_check("Host effective source root", source_root),
|
||||||
|
_ssh_user_check(ssh_user),
|
||||||
|
_ssh_port_check("Host effective SSH port", ssh_port),
|
||||||
|
_credential_check("Host effective SSH credential", credential),
|
||||||
|
_rsync_recursion_check("Host effective rsync recursion", rsync_args),
|
||||||
|
_root_excludes_check(source_root, excludes, host=host),
|
||||||
|
_includes_check(host),
|
||||||
|
_retention_check(
|
||||||
|
"Host retention",
|
||||||
|
host.retention_daily,
|
||||||
|
host.retention_weekly,
|
||||||
|
host.retention_monthly,
|
||||||
|
host.retention_yearly,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def has_recursive_rsync_arg(args: list[str]) -> bool:
|
||||||
|
for arg in args:
|
||||||
|
if arg in {"--archive", "--recursive"}:
|
||||||
|
return True
|
||||||
|
if arg.startswith("-") and not arg.startswith("--") and any(flag in arg for flag in ("a", "r")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_path_check(name: str, value: str) -> SelfCheck:
|
||||||
|
path = Path(value)
|
||||||
|
if not value:
|
||||||
|
return SelfCheck(name, "failed", "Path is empty.")
|
||||||
|
if not path.is_absolute():
|
||||||
|
return SelfCheck(name, "failed", f"{value} is not absolute.")
|
||||||
|
return SelfCheck(name, "ok", value)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_backup_root_check(global_config: GlobalConfig) -> SelfCheck:
|
||||||
|
if global_config.backup_root == settings.POBSYNC_BACKUP_ROOT:
|
||||||
|
return SelfCheck("Runtime backup root", "ok", global_config.backup_root)
|
||||||
|
return SelfCheck(
|
||||||
|
"Runtime backup root",
|
||||||
|
"warning",
|
||||||
|
"Database backup root differs from the runtime backup root.",
|
||||||
|
f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rsync_binary_check(binary: str) -> SelfCheck:
|
||||||
|
if not binary:
|
||||||
|
return SelfCheck("Global rsync binary", "failed", "Rsync binary is empty.")
|
||||||
|
if Path(binary).is_absolute():
|
||||||
|
exists = Path(binary).exists()
|
||||||
|
message = binary if exists else f"{binary} does not exist."
|
||||||
|
return SelfCheck("Global rsync binary", "ok" if exists else "failed", message)
|
||||||
|
path = shutil.which(binary)
|
||||||
|
return SelfCheck("Global rsync binary", "ok" if path else "failed", path or f"{binary} was not found in PATH.")
|
||||||
|
|
||||||
|
|
||||||
|
def _rsync_recursion_check(name: str, args: list[str]) -> SelfCheck:
|
||||||
|
if has_recursive_rsync_arg(args):
|
||||||
|
return SelfCheck(name, "ok", "Rsync args include archive or recursive transfer.", " ".join(args))
|
||||||
|
return SelfCheck(
|
||||||
|
name,
|
||||||
|
"failed",
|
||||||
|
"Rsync args do not include archive or recursive transfer.",
|
||||||
|
"Add --archive or --recursive before running a real backup.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_root_check(name: str, source_root: str) -> SelfCheck:
|
||||||
|
if not source_root:
|
||||||
|
return SelfCheck(name, "failed", "Source root is empty.")
|
||||||
|
if not source_root.startswith("/"):
|
||||||
|
return SelfCheck(name, "failed", f"{source_root} is not absolute.")
|
||||||
|
return SelfCheck(name, "ok", source_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _root_excludes_check(source_root: str, excludes: list[str], host: HostConfig | None = None) -> SelfCheck:
|
||||||
|
if source_root != "/":
|
||||||
|
return SelfCheck("Effective root excludes", "ok", "Source root is not /, critical OS excludes are less important.")
|
||||||
|
missing = [pattern for pattern in CRITICAL_ROOT_EXCLUDES if pattern not in excludes]
|
||||||
|
if missing:
|
||||||
|
detail = ", ".join(missing)
|
||||||
|
if host and host.excludes_replace is not None:
|
||||||
|
detail = f"excludes_replace is active; missing {detail}"
|
||||||
|
return SelfCheck(
|
||||||
|
"Effective root excludes",
|
||||||
|
"warning",
|
||||||
|
"Source root is / but some critical default excludes are missing.",
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
return SelfCheck("Effective root excludes", "ok", "Critical root excludes are present.")
|
||||||
|
|
||||||
|
|
||||||
|
def _includes_check(host: HostConfig) -> SelfCheck:
|
||||||
|
includes = list(host.includes or [])
|
||||||
|
if not includes:
|
||||||
|
return SelfCheck("Host includes", "ok", "No host include rules are configured.")
|
||||||
|
return SelfCheck(
|
||||||
|
"Host includes",
|
||||||
|
"warning",
|
||||||
|
"Includes are passed to rsync as raw --include rules.",
|
||||||
|
"Verify matching exclude rules if you intend to limit the backup scope.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _retention_check(name: str, daily: int, weekly: int, monthly: int, yearly: int) -> SelfCheck:
|
||||||
|
if any(value > 0 for value in (daily, weekly, monthly, yearly)):
|
||||||
|
return SelfCheck(name, "ok", f"d{daily} w{weekly} m{monthly} y{yearly}")
|
||||||
|
return SelfCheck(name, "warning", "All retention windows are zero.")
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_user_check(user: str) -> SelfCheck:
|
||||||
|
if user.strip():
|
||||||
|
return SelfCheck("Host effective SSH user", "ok", user.strip())
|
||||||
|
return SelfCheck("Host effective SSH user", "failed", "SSH user is empty.")
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_port_check(name: str, port: int | None) -> SelfCheck:
|
||||||
|
if port is None:
|
||||||
|
return SelfCheck(name, "failed", "SSH port is empty.")
|
||||||
|
if 1 <= int(port) <= 65535:
|
||||||
|
return SelfCheck(name, "ok", str(port))
|
||||||
|
return SelfCheck(name, "failed", f"{port} is outside the valid TCP port range.")
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_check(name: str, credential: SshCredential | None) -> SelfCheck:
|
||||||
|
if credential is None:
|
||||||
|
return SelfCheck(name, "warning", "No SSH credential selected.")
|
||||||
|
if credential.key_path:
|
||||||
|
key_path = identity_path(credential)
|
||||||
|
if not key_path.exists():
|
||||||
|
return SelfCheck(name, "failed", f"{key_path} does not exist.")
|
||||||
|
if not os.access(key_path, os.R_OK):
|
||||||
|
return SelfCheck(name, "failed", f"{key_path} is not readable by this process.")
|
||||||
|
return SelfCheck(name, "ok", str(credential), str(key_path))
|
||||||
|
if credential.private_key:
|
||||||
|
return SelfCheck(
|
||||||
|
name,
|
||||||
|
"warning",
|
||||||
|
f"{credential} stores private key material in the database.",
|
||||||
|
"Generated filesystem keys are recommended for native installs.",
|
||||||
|
)
|
||||||
|
return SelfCheck(name, "failed", f"{credential} has no private key material or key path.")
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
from pobsync.config.schemas import GLOBAL_SCHEMA, HOST_SCHEMA
|
from pobsync.config.schemas import GLOBAL_SCHEMA, HOST_SCHEMA
|
||||||
from pobsync.paths import PobsyncPaths
|
|
||||||
from pobsync.util import write_yaml_atomic
|
|
||||||
from pobsync.validate import validate_dict
|
from pobsync.validate import validate_dict
|
||||||
|
|
||||||
from .models import GlobalConfig, HostConfig
|
from .models import GlobalConfig, HostConfig
|
||||||
@@ -17,10 +14,9 @@ class ConfigRepositoryError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _global_yaml_data(global_config: GlobalConfig) -> dict[str, Any]:
|
def _global_runtime_data(global_config: GlobalConfig) -> dict[str, Any]:
|
||||||
data = {
|
data = {
|
||||||
"backup_root": global_config.backup_root,
|
"backup_root": global_config.backup_root,
|
||||||
"pobsync_home": global_config.pobsync_home,
|
|
||||||
"ssh": {
|
"ssh": {
|
||||||
"user": global_config.ssh_user,
|
"user": global_config.ssh_user,
|
||||||
"port": global_config.ssh_port,
|
"port": global_config.ssh_port,
|
||||||
@@ -48,7 +44,7 @@ def _global_yaml_data(global_config: GlobalConfig) -> dict[str, Any]:
|
|||||||
return validate_dict(data, GLOBAL_SCHEMA, path="global")
|
return validate_dict(data, GLOBAL_SCHEMA, path="global")
|
||||||
|
|
||||||
|
|
||||||
def _host_yaml_data(host_config: HostConfig) -> dict[str, Any]:
|
def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]:
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
"host": host_config.host,
|
"host": host_config.host,
|
||||||
"address": host_config.address,
|
"address": host_config.address,
|
||||||
@@ -77,50 +73,25 @@ def _host_yaml_data(host_config: HostConfig) -> dict[str, Any]:
|
|||||||
return validate_dict(data, HOST_SCHEMA, path="host")
|
return validate_dict(data, HOST_SCHEMA, path="host")
|
||||||
|
|
||||||
|
|
||||||
|
def global_config_object_data(global_config: GlobalConfig) -> dict[str, Any]:
|
||||||
|
return _global_runtime_data(global_config)
|
||||||
|
|
||||||
|
|
||||||
|
def host_config_object_data(host_config: HostConfig) -> dict[str, Any]:
|
||||||
|
return _host_runtime_data(host_config)
|
||||||
|
|
||||||
|
|
||||||
def global_config_data(name: str = "default") -> dict[str, Any]:
|
def global_config_data(name: str = "default") -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
global_config = GlobalConfig.objects.get(name=name)
|
global_config = GlobalConfig.objects.get(name=name)
|
||||||
except ObjectDoesNotExist as exc:
|
except ObjectDoesNotExist as exc:
|
||||||
raise ConfigRepositoryError(f"Missing GlobalConfig {name!r}") from exc
|
raise ConfigRepositoryError(f"Missing global config {name!r}") from exc
|
||||||
return _global_yaml_data(global_config)
|
return _global_runtime_data(global_config)
|
||||||
|
|
||||||
|
|
||||||
def host_config_data(host: str) -> dict[str, Any]:
|
def host_config_data(host: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
host_config = HostConfig.objects.get(host=host, enabled=True)
|
host_config = HostConfig.objects.get(host=host, enabled=True)
|
||||||
except ObjectDoesNotExist as exc:
|
except ObjectDoesNotExist as exc:
|
||||||
raise ConfigRepositoryError(f"Missing enabled HostConfig {host!r}") from exc
|
raise ConfigRepositoryError(f"Missing enabled host {host!r}") from exc
|
||||||
return _host_yaml_data(host_config)
|
return _host_runtime_data(host_config)
|
||||||
|
|
||||||
|
|
||||||
def export_global_config(prefix: Path, name: str = "default") -> Path:
|
|
||||||
try:
|
|
||||||
global_config = GlobalConfig.objects.get(name=name)
|
|
||||||
except ObjectDoesNotExist as exc:
|
|
||||||
raise ConfigRepositoryError(f"Missing GlobalConfig {name!r}") from exc
|
|
||||||
|
|
||||||
paths = PobsyncPaths(home=prefix)
|
|
||||||
write_yaml_atomic(paths.global_config_path, _global_yaml_data(global_config))
|
|
||||||
return paths.global_config_path
|
|
||||||
|
|
||||||
|
|
||||||
def export_host_config(prefix: Path, host: str) -> Path:
|
|
||||||
try:
|
|
||||||
host_config = HostConfig.objects.get(host=host, enabled=True)
|
|
||||||
except ObjectDoesNotExist as exc:
|
|
||||||
raise ConfigRepositoryError(f"Missing enabled HostConfig {host!r}") from exc
|
|
||||||
|
|
||||||
paths = PobsyncPaths(home=prefix)
|
|
||||||
target = paths.hosts_dir / f"{host_config.host}.yaml"
|
|
||||||
write_yaml_atomic(target, _host_yaml_data(host_config))
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def export_runtime_configs(prefix: Path, host: str | None = None) -> list[Path]:
|
|
||||||
written = [export_global_config(prefix)]
|
|
||||||
hosts = HostConfig.objects.filter(enabled=True).order_by("host")
|
|
||||||
if host is not None:
|
|
||||||
hosts = hosts.filter(host=host)
|
|
||||||
for host_config in hosts:
|
|
||||||
written.append(export_host_config(prefix, host_config.host))
|
|
||||||
return written
|
|
||||||
|
|||||||
@@ -1,12 +1,93 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from pobsync.config.merge import build_effective_config
|
from pobsync.config.merge import build_effective_config
|
||||||
|
from pobsync.paths import PobsyncPaths
|
||||||
|
|
||||||
from .config_repository import global_config_data, host_config_data
|
from .config_repository import global_config_data, host_config_data
|
||||||
|
from .models import GlobalConfig, HostConfig, SshCredential
|
||||||
|
from .ssh_keys import identity_path
|
||||||
|
|
||||||
|
|
||||||
class DjangoConfigSource:
|
class DjangoConfigSource:
|
||||||
def effective_config_for_host(self, host: str) -> dict[str, Any]:
|
def effective_config_for_host(self, host: str) -> dict[str, Any]:
|
||||||
return build_effective_config(global_config_data(), host_config_data(host))
|
config = build_effective_config(global_config_data(), host_config_data(host))
|
||||||
|
credential = _credential_for_host(host)
|
||||||
|
if credential is not None:
|
||||||
|
_attach_credential_options(config, credential)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_for_host(host: str) -> SshCredential | None:
|
||||||
|
host_config = HostConfig.objects.select_related("ssh_credential").get(host=host, enabled=True)
|
||||||
|
if host_config.ssh_credential_id:
|
||||||
|
return host_config.ssh_credential
|
||||||
|
|
||||||
|
global_config = GlobalConfig.objects.select_related("default_ssh_credential").get(name="default")
|
||||||
|
return global_config.default_ssh_credential
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_credential_options(config: dict[str, Any], credential: SshCredential) -> None:
|
||||||
|
ssh = config.setdefault("ssh", {})
|
||||||
|
options = list(ssh.get("options") or [])
|
||||||
|
paths = _materialize_credential(credential)
|
||||||
|
if not _has_ssh_option(options, "IdentityFile"):
|
||||||
|
options.append(f"-oIdentityFile={paths['identity_file']}")
|
||||||
|
if paths.get("known_hosts") and not _has_ssh_option(options, "UserKnownHostsFile"):
|
||||||
|
options.append(f"-oUserKnownHostsFile={paths['known_hosts']}")
|
||||||
|
if paths.get("accept_new_known_hosts"):
|
||||||
|
if not _has_ssh_option(options, "UserKnownHostsFile"):
|
||||||
|
options.append(f"-oUserKnownHostsFile={paths['accept_new_known_hosts']}")
|
||||||
|
if not _has_ssh_option(options, "StrictHostKeyChecking"):
|
||||||
|
options.append("-oStrictHostKeyChecking=accept-new")
|
||||||
|
ssh["options"] = options
|
||||||
|
config["ssh_credential"] = {
|
||||||
|
"id": credential.pk,
|
||||||
|
"name": credential.name,
|
||||||
|
"identity_file": paths["identity_file"],
|
||||||
|
"generated": credential.generated,
|
||||||
|
"storage": "filesystem" if credential.key_path else "database",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_credential(credential: SshCredential) -> dict[str, str]:
|
||||||
|
paths = PobsyncPaths(home=Path(settings.POBSYNC_HOME))
|
||||||
|
credential_dir = paths.state_dir / "ssh-credentials" / str(credential.pk)
|
||||||
|
credential_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
os.chmod(credential_dir, 0o700)
|
||||||
|
|
||||||
|
identity_file = identity_path(credential)
|
||||||
|
if credential.key_path:
|
||||||
|
os.chmod(identity_file, 0o600)
|
||||||
|
else:
|
||||||
|
identity_file.write_text(_with_trailing_newline(credential.private_key), encoding="utf-8")
|
||||||
|
os.chmod(identity_file, 0o600)
|
||||||
|
|
||||||
|
result = {"identity_file": str(identity_file)}
|
||||||
|
if credential.known_hosts.strip():
|
||||||
|
known_hosts = credential_dir / "known_hosts"
|
||||||
|
known_hosts.write_text(_with_trailing_newline(credential.known_hosts), encoding="utf-8")
|
||||||
|
os.chmod(known_hosts, 0o600)
|
||||||
|
result["known_hosts"] = str(known_hosts)
|
||||||
|
else:
|
||||||
|
known_hosts = paths.state_dir / "known_hosts"
|
||||||
|
known_hosts.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
known_hosts.touch(mode=0o600, exist_ok=True)
|
||||||
|
os.chmod(known_hosts, 0o600)
|
||||||
|
result["accept_new_known_hosts"] = str(known_hosts)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _has_ssh_option(options: list[str], name: str) -> bool:
|
||||||
|
prefix = f"-o{name}="
|
||||||
|
spaced = f"-o{name} "
|
||||||
|
return any(option == name or option.startswith(prefix) or option.startswith(spaced) for option in options)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_trailing_newline(value: str) -> str:
|
||||||
|
return value if value.endswith("\n") else f"{value}\n"
|
||||||
|
|||||||
409
src/pobsync_backend/forms.py
Normal file
409
src/pobsync_backend/forms.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .models import GlobalConfig, HostConfig, ScheduleConfig, SshCredential
|
||||||
|
from .scheduler import parse_cron_expr
|
||||||
|
|
||||||
|
|
||||||
|
class NewlineListField(forms.CharField):
|
||||||
|
widget = forms.Textarea
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
kwargs.setdefault("required", False)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def prepare_value(self, value):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "\n".join(str(item) for item in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_python(self, value) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
return [line.strip() for line in str(value).splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class NullableNewlineListField(NewlineListField):
|
||||||
|
def to_python(self, value) -> list[str] | None:
|
||||||
|
parsed = super().to_python(value)
|
||||||
|
return parsed or None
|
||||||
|
|
||||||
|
|
||||||
|
class HostConfigForm(forms.ModelForm):
|
||||||
|
includes = NewlineListField(help_text="One include path per line. Leave empty to include defaults.")
|
||||||
|
excludes_add = NewlineListField(help_text="One additional exclude pattern per line.")
|
||||||
|
excludes_replace = NullableNewlineListField(
|
||||||
|
help_text="Optional. When set, replaces global excludes; one pattern per line."
|
||||||
|
)
|
||||||
|
rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = HostConfig
|
||||||
|
fields = (
|
||||||
|
"address",
|
||||||
|
"enabled",
|
||||||
|
"ssh_credential",
|
||||||
|
"ssh_user",
|
||||||
|
"ssh_port",
|
||||||
|
"source_root",
|
||||||
|
"includes",
|
||||||
|
"excludes_add",
|
||||||
|
"excludes_replace",
|
||||||
|
"rsync_extra_args",
|
||||||
|
"retention_daily",
|
||||||
|
"retention_weekly",
|
||||||
|
"retention_monthly",
|
||||||
|
"retention_yearly",
|
||||||
|
)
|
||||||
|
help_texts = {
|
||||||
|
"ssh_credential": "Optional. Overrides the global SSH credential for this host.",
|
||||||
|
"ssh_user": "Leave empty to use the global SSH user.",
|
||||||
|
"ssh_port": "Leave empty to use the global SSH port.",
|
||||||
|
"source_root": "Leave empty to use the global default source root.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHostConfigForm(HostConfigForm):
|
||||||
|
class Meta(HostConfigForm.Meta):
|
||||||
|
fields = ("host", *HostConfigForm.Meta.fields)
|
||||||
|
help_texts = {
|
||||||
|
**HostConfigForm.Meta.help_texts,
|
||||||
|
"host": "Stable internal host name used for backup paths.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalConfigForm(forms.ModelForm):
|
||||||
|
ssh_options = NewlineListField(help_text="One SSH option per line.")
|
||||||
|
rsync_args = NewlineListField(help_text="One default rsync argument per line.")
|
||||||
|
rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.")
|
||||||
|
excludes_default = NewlineListField(help_text="One default exclude pattern per line.")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = GlobalConfig
|
||||||
|
fields = (
|
||||||
|
"name",
|
||||||
|
"default_ssh_credential",
|
||||||
|
"ssh_user",
|
||||||
|
"ssh_port",
|
||||||
|
"ssh_options",
|
||||||
|
"rsync_binary",
|
||||||
|
"rsync_args",
|
||||||
|
"rsync_extra_args",
|
||||||
|
"rsync_timeout_seconds",
|
||||||
|
"rsync_bwlimit_kbps",
|
||||||
|
"default_source_root",
|
||||||
|
"default_destination_subdir",
|
||||||
|
"excludes_default",
|
||||||
|
"retention_daily",
|
||||||
|
"retention_weekly",
|
||||||
|
"retention_monthly",
|
||||||
|
"retention_yearly",
|
||||||
|
)
|
||||||
|
help_texts = {
|
||||||
|
"name": "Usually 'default'. The backup engine currently reads the default config.",
|
||||||
|
"default_ssh_credential": "Optional. Used by hosts without their own SSH credential.",
|
||||||
|
"default_source_root": "Used by hosts without a custom source root.",
|
||||||
|
"default_destination_subdir": "Optional subdirectory below each snapshot.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self, commit: bool = True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
instance.backup_root = settings.POBSYNC_BACKUP_ROOT
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
self.save_m2m()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class ManualBackupForm(forms.Form):
|
||||||
|
dry_run = forms.BooleanField(
|
||||||
|
label="Dry run",
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
help_text="Queue rsync in dry-run mode without writing a snapshot.",
|
||||||
|
)
|
||||||
|
verbose_output = forms.BooleanField(
|
||||||
|
label="Verbose rsync output",
|
||||||
|
required=False,
|
||||||
|
help_text="Write itemized rsync changes, file-list progress, and stats to the run log. Dry-runs always use this.",
|
||||||
|
)
|
||||||
|
prune = forms.BooleanField(
|
||||||
|
label="Apply retention after success",
|
||||||
|
required=False,
|
||||||
|
help_text="Apply retention after a successful non-dry-run backup.",
|
||||||
|
)
|
||||||
|
prune_max_delete = forms.IntegerField(label="Retention max delete", min_value=0, initial=10)
|
||||||
|
prune_protect_bases = forms.BooleanField(
|
||||||
|
label="Protect base snapshots",
|
||||||
|
required=False,
|
||||||
|
help_text="Keep snapshots that are used as bases by other snapshots.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SshCredentialForm(forms.ModelForm):
|
||||||
|
private_key_file = forms.FileField(
|
||||||
|
required=False,
|
||||||
|
help_text="Optional. Upload the private key file directly to avoid copy/paste formatting problems.",
|
||||||
|
)
|
||||||
|
private_key = forms.CharField(
|
||||||
|
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
|
||||||
|
required=False,
|
||||||
|
help_text=(
|
||||||
|
"Paste the complete unencrypted OpenSSH private key, including BEGIN/END lines. "
|
||||||
|
"Leave empty when uploading a private key file."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
public_key = forms.CharField(
|
||||||
|
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
|
||||||
|
required=False,
|
||||||
|
help_text="Optional. If set, pobsync verifies it matches the private key.",
|
||||||
|
)
|
||||||
|
known_hosts = forms.CharField(
|
||||||
|
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
|
||||||
|
required=False,
|
||||||
|
help_text="Optional known_hosts entries. When set, StrictHostKeyChecking can stay enabled.",
|
||||||
|
)
|
||||||
|
notes = forms.CharField(widget=forms.Textarea, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SshCredential
|
||||||
|
fields = ("name", "private_key", "public_key", "known_hosts", "notes")
|
||||||
|
|
||||||
|
def clean_private_key(self) -> str:
|
||||||
|
uploaded_file = self.files.get("private_key_file")
|
||||||
|
if uploaded_file:
|
||||||
|
try:
|
||||||
|
raw_private_key = uploaded_file.read().decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise forms.ValidationError("SSH private key files must be UTF-8 text files.") from exc
|
||||||
|
else:
|
||||||
|
raw_private_key = self.cleaned_data.get("private_key", "")
|
||||||
|
|
||||||
|
if not raw_private_key.strip():
|
||||||
|
if self.instance and self.instance.pk and self.instance.key_path:
|
||||||
|
return self.instance.private_key
|
||||||
|
raise forms.ValidationError("Paste a private key, upload a private key file, or generate a key in pobsync.")
|
||||||
|
|
||||||
|
private_key = normalize_private_key(raw_private_key)
|
||||||
|
public_key = validate_ssh_private_key(private_key)
|
||||||
|
self.derived_public_key = public_key
|
||||||
|
return f"{private_key}\n"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
provided_public_key = normalize_public_key(cleaned_data.get("public_key", ""))
|
||||||
|
if provided_public_key:
|
||||||
|
cleaned_data["public_key"] = provided_public_key
|
||||||
|
elif self.instance and self.instance.pk and self.instance.key_path:
|
||||||
|
cleaned_data["public_key"] = self.instance.public_key
|
||||||
|
|
||||||
|
if cleaned_data.get("private_key") and provided_public_key and hasattr(self, "derived_public_key"):
|
||||||
|
if public_key_identity(provided_public_key) != public_key_identity(self.derived_public_key):
|
||||||
|
self.add_error(
|
||||||
|
"public_key",
|
||||||
|
forms.ValidationError("Public key does not match the supplied private key."),
|
||||||
|
)
|
||||||
|
elif cleaned_data.get("private_key") and not provided_public_key and hasattr(self, "derived_public_key"):
|
||||||
|
cleaned_data["public_key"] = self.derived_public_key
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class SshCredentialGenerateForm(forms.Form):
|
||||||
|
name = forms.CharField(max_length=128)
|
||||||
|
key_type = forms.ChoiceField(
|
||||||
|
choices=(("ed25519", "ed25519"), ("rsa", "rsa")),
|
||||||
|
initial="ed25519",
|
||||||
|
help_text="ed25519 is recommended unless you need RSA for an older target.",
|
||||||
|
)
|
||||||
|
set_global_default = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
help_text="Use this key as the global default when the default global config exists.",
|
||||||
|
)
|
||||||
|
known_hosts = forms.CharField(
|
||||||
|
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
|
||||||
|
required=False,
|
||||||
|
help_text="Optional known_hosts entries. This can also be filled later.",
|
||||||
|
)
|
||||||
|
notes = forms.CharField(widget=forms.Textarea, required=False)
|
||||||
|
|
||||||
|
def clean_name(self) -> str:
|
||||||
|
name = self.cleaned_data["name"].strip()
|
||||||
|
if SshCredential.objects.filter(name=name).exists():
|
||||||
|
raise forms.ValidationError("An SSH credential with this name already exists.")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionApplyForm(forms.Form):
|
||||||
|
kind = forms.ChoiceField(choices=(("scheduled", "Scheduled"), ("manual", "Manual"), ("all", "All")))
|
||||||
|
protect_bases = forms.BooleanField(required=False)
|
||||||
|
max_delete = forms.IntegerField(min_value=0, initial=10)
|
||||||
|
confirm_delete_count = forms.IntegerField(min_value=0)
|
||||||
|
confirm_host = forms.CharField()
|
||||||
|
|
||||||
|
def __init__(self, *args, host_name: str, expected_delete_count: int | None = None, **kwargs) -> None:
|
||||||
|
self.host_name = host_name
|
||||||
|
self.expected_delete_count = expected_delete_count
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["confirm_host"].help_text = f"Type {host_name} to confirm deletion."
|
||||||
|
if expected_delete_count is not None:
|
||||||
|
self.fields["confirm_delete_count"].help_text = (
|
||||||
|
f"Type {expected_delete_count} to confirm the current number of planned deletions."
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_confirm_host(self) -> str:
|
||||||
|
value = self.cleaned_data["confirm_host"].strip()
|
||||||
|
if value != self.host_name:
|
||||||
|
raise forms.ValidationError(f"Type {self.host_name} to confirm.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def clean_confirm_delete_count(self) -> int:
|
||||||
|
value = self.cleaned_data["confirm_delete_count"]
|
||||||
|
if self.expected_delete_count is not None and value != self.expected_delete_count:
|
||||||
|
raise forms.ValidationError(f"Type {self.expected_delete_count} to confirm the delete count.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class IncompleteCleanupForm(forms.Form):
|
||||||
|
max_delete = forms.IntegerField(min_value=0, initial=0)
|
||||||
|
confirm_delete_count = forms.IntegerField(min_value=0)
|
||||||
|
confirm_host = forms.CharField()
|
||||||
|
|
||||||
|
def __init__(self, *args, host_name: str, expected_delete_count: int, **kwargs) -> None:
|
||||||
|
self.host_name = host_name
|
||||||
|
self.expected_delete_count = expected_delete_count
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["confirm_host"].help_text = f"Type {host_name} to confirm incomplete snapshot cleanup."
|
||||||
|
self.fields["confirm_delete_count"].help_text = (
|
||||||
|
f"Type {expected_delete_count} to confirm the current number of incomplete snapshots."
|
||||||
|
)
|
||||||
|
self.fields["max_delete"].help_text = (
|
||||||
|
f"Must be at least {expected_delete_count} for the incomplete snapshots shown here."
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_confirm_host(self) -> str:
|
||||||
|
value = self.cleaned_data["confirm_host"].strip()
|
||||||
|
if value != self.host_name:
|
||||||
|
raise forms.ValidationError(f"Type {self.host_name} to confirm.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def clean_confirm_delete_count(self) -> int:
|
||||||
|
value = self.cleaned_data["confirm_delete_count"]
|
||||||
|
if value != self.expected_delete_count:
|
||||||
|
raise forms.ValidationError(f"Type {self.expected_delete_count} to confirm the incomplete count.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleConfigForm(forms.ModelForm):
|
||||||
|
cron_expr = forms.CharField(
|
||||||
|
label="Schedule expression",
|
||||||
|
help_text=(
|
||||||
|
'Five-field cron-style expression, for example "15 2 * * *". '
|
||||||
|
"This is evaluated by the pobsync scheduler service, not host cron."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
prune_max_delete = forms.IntegerField(min_value=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ScheduleConfig
|
||||||
|
fields = (
|
||||||
|
"cron_expr",
|
||||||
|
"enabled",
|
||||||
|
"prune",
|
||||||
|
"prune_max_delete",
|
||||||
|
"prune_protect_bases",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_cron_expr(self) -> str:
|
||||||
|
cron_expr = self.cleaned_data["cron_expr"].strip()
|
||||||
|
try:
|
||||||
|
parse_cron_expr(cron_expr)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise forms.ValidationError(str(exc)) from exc
|
||||||
|
return cron_expr
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_private_key(private_key: str) -> str:
|
||||||
|
normalized = private_key.replace("\r\n", "\n").replace("\r", "\n").strip().lstrip("\ufeff")
|
||||||
|
|
||||||
|
begin_marker = "-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
end_marker = "-----END OPENSSH PRIVATE KEY-----"
|
||||||
|
if begin_marker in normalized and end_marker in normalized:
|
||||||
|
before_body, after_begin = normalized.split(begin_marker, 1)
|
||||||
|
body, after_end = after_begin.split(end_marker, 1)
|
||||||
|
if before_body.strip() or after_end.strip():
|
||||||
|
return normalized
|
||||||
|
compact_body = "".join(body.split())
|
||||||
|
wrapped_body = "\n".join(textwrap.wrap(compact_body, width=70))
|
||||||
|
return f"{begin_marker}\n{wrapped_body}\n{end_marker}"
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_public_key(public_key: str) -> str:
|
||||||
|
return " ".join(public_key.strip().split())
|
||||||
|
|
||||||
|
|
||||||
|
def public_key_identity(public_key: str) -> str:
|
||||||
|
parts = normalize_public_key(public_key).split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return " ".join(parts[:2])
|
||||||
|
return normalize_public_key(public_key)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ssh_private_key(private_key: str) -> str:
|
||||||
|
if "BEGIN OPENSSH PRIVATE KEY" not in private_key:
|
||||||
|
stripped = private_key.strip()
|
||||||
|
if stripped.startswith(("ssh-ed25519 ", "ssh-rsa ", "ecdsa-sha2-", "sk-")):
|
||||||
|
raise forms.ValidationError("This looks like a public key. Paste the private key in this field.")
|
||||||
|
if "BEGIN RSA PRIVATE KEY" in stripped or "BEGIN EC PRIVATE KEY" in stripped:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"PEM private keys are not supported here yet. Convert it to an unencrypted OpenSSH key first."
|
||||||
|
)
|
||||||
|
raise forms.ValidationError("Invalid SSH private key: missing OpenSSH private key header.")
|
||||||
|
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
key_path = Path(tmp) / "identity"
|
||||||
|
key_path.write_text(f"{private_key}\n", encoding="utf-8")
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ssh-keygen", "-y", "-f", str(key_path)],
|
||||||
|
check=False,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise forms.ValidationError("ssh-keygen is not available in this container.") from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise forms.ValidationError("Could not validate SSH private key before timeout.") from exc
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
message = result.stderr.strip() or "OpenSSH could not read this private key."
|
||||||
|
lower_message = message.lower()
|
||||||
|
if "passphrase" in lower_message:
|
||||||
|
message = "Encrypted SSH private keys are not supported for unattended backups."
|
||||||
|
elif "libcrypto" in lower_message:
|
||||||
|
message = (
|
||||||
|
"OpenSSH could not parse this key. It is usually incomplete, corrupted while copying, "
|
||||||
|
"or not an unencrypted OpenSSH private key."
|
||||||
|
)
|
||||||
|
raise forms.ValidationError(f"Invalid SSH private key: {message}")
|
||||||
|
|
||||||
|
public_key = result.stdout.strip()
|
||||||
|
if not public_key:
|
||||||
|
raise forms.ValidationError("Invalid SSH private key: no public key could be derived.")
|
||||||
|
return public_key
|
||||||
103
src/pobsync_backend/host_ops.py
Normal file
103
src/pobsync_backend/host_ops.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pobsync.snapshot_meta import resolve_host_root
|
||||||
|
|
||||||
|
from .config_checks import collect_effective_host_config_checks
|
||||||
|
from .models import GlobalConfig, HostConfig
|
||||||
|
from .self_check import SelfCheck
|
||||||
|
from .ssh_keys import identity_path
|
||||||
|
|
||||||
|
|
||||||
|
HOST_BACKUP_SUBDIRS = ("scheduled", "manual", ".incomplete")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_host_directories(host: HostConfig, global_config: GlobalConfig | None = None) -> Path:
|
||||||
|
global_config = global_config or GlobalConfig.objects.get(name="default")
|
||||||
|
host_root = resolve_host_root(global_config.backup_root, host.host)
|
||||||
|
for subdir in HOST_BACKUP_SUBDIRS:
|
||||||
|
(host_root / subdir).mkdir(parents=True, exist_ok=True)
|
||||||
|
return host_root
|
||||||
|
|
||||||
|
|
||||||
|
def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = None) -> list[SelfCheck]:
|
||||||
|
checks: list[SelfCheck] = []
|
||||||
|
try:
|
||||||
|
global_config = global_config or GlobalConfig.objects.get(name="default")
|
||||||
|
except GlobalConfig.DoesNotExist:
|
||||||
|
return [SelfCheck("Host global config", "failed", "Default global config does not exist.")]
|
||||||
|
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
"Host enabled",
|
||||||
|
"ok" if host.enabled else "warning",
|
||||||
|
"Host is enabled." if host.enabled else "Host is disabled.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
"Host address",
|
||||||
|
"ok" if host.address.strip() else "failed",
|
||||||
|
host.address.strip() or "Host address is empty.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
credential = host.ssh_credential or global_config.default_ssh_credential
|
||||||
|
if credential is None:
|
||||||
|
checks.append(SelfCheck("Host SSH credential", "warning", "No host or global SSH credential selected."))
|
||||||
|
else:
|
||||||
|
checks.append(SelfCheck("Host SSH credential", "ok", str(credential)))
|
||||||
|
if credential.key_path:
|
||||||
|
key_path = identity_path(credential)
|
||||||
|
checks.append(
|
||||||
|
_host_path_check("Host SSH key file", key_path, must_exist=True, must_be_writable=False, must_be_readable=True)
|
||||||
|
)
|
||||||
|
elif credential.private_key:
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
"Host SSH key storage",
|
||||||
|
"warning",
|
||||||
|
"Selected credential stores private key material in the database.",
|
||||||
|
"Generated filesystem keys are recommended for native systemd installs.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if credential.known_hosts.strip():
|
||||||
|
checks.append(SelfCheck("Host known_hosts", "ok", "Selected credential has known_hosts entries."))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
"Host known_hosts",
|
||||||
|
"warning",
|
||||||
|
"Selected credential has no pinned known_hosts entries.",
|
||||||
|
"pobsync will use service-level StrictHostKeyChecking=accept-new on first connect.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
host_root = resolve_host_root(global_config.backup_root, host.host)
|
||||||
|
checks.append(_host_path_check("Host backup root", host_root, must_exist=True, must_be_writable=True))
|
||||||
|
for subdir in HOST_BACKUP_SUBDIRS:
|
||||||
|
checks.append(_host_path_check(f"Host directory: {subdir}", host_root / subdir, must_exist=True, must_be_writable=True))
|
||||||
|
checks.extend(collect_effective_host_config_checks(host, global_config))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _host_path_check(
|
||||||
|
name: str,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
must_exist: bool,
|
||||||
|
must_be_writable: bool,
|
||||||
|
must_be_readable: bool = False,
|
||||||
|
) -> SelfCheck:
|
||||||
|
if must_exist and not path.exists():
|
||||||
|
return SelfCheck(name, "failed", f"{path} does not exist.")
|
||||||
|
target = path if path.exists() else path.parent
|
||||||
|
if not target.exists():
|
||||||
|
return SelfCheck(name, "failed", f"{target} does not exist.")
|
||||||
|
if must_be_writable and not os.access(target, os.W_OK):
|
||||||
|
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
|
||||||
|
if must_be_readable and not os.access(target, os.R_OK):
|
||||||
|
return SelfCheck(name, "failed", f"{target} is not readable by this process.")
|
||||||
|
return SelfCheck(name, "ok", str(path))
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from pobsync_backend.self_check import collect_self_checks, summarize_self_checks
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Run pobsync runtime self checks for native installs and updates."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--fail-on-warning",
|
||||||
|
action="store_true",
|
||||||
|
help="Exit with an error when warnings are present.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
checks = collect_self_checks()
|
||||||
|
summary = summarize_self_checks(checks)
|
||||||
|
|
||||||
|
for check in checks:
|
||||||
|
line = f"[{check.status.upper()}] {check.name}: {check.message}"
|
||||||
|
if check.detail:
|
||||||
|
line = f"{line} ({check.detail})"
|
||||||
|
if check.status == "failed":
|
||||||
|
self.stderr.write(line)
|
||||||
|
elif check.status == "warning":
|
||||||
|
self.stderr.write(line)
|
||||||
|
else:
|
||||||
|
self.stdout.write(line)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
"Summary: "
|
||||||
|
f"{summary['ok']} ok, "
|
||||||
|
f"{summary['warning']} warning(s), "
|
||||||
|
f"{summary['failed']} failed, "
|
||||||
|
f"{summary['skipped']} skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
if summary["failed"]:
|
||||||
|
raise CommandError("pobsync install self check failed.")
|
||||||
|
if options["fail_on_warning"] and summary["warning"]:
|
||||||
|
raise CommandError("pobsync install self check reported warnings.")
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
from pobsync.config.retention import parse_retention
|
from pobsync.config.retention import parse_retention
|
||||||
@@ -13,12 +11,11 @@ from pobsync_backend.models import GlobalConfig
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Create or update the SQL-backed global pobsync configuration."
|
help = "Create or update the default global backup configuration."
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
parser.add_argument("--name", default="default")
|
parser.add_argument("--name", default="default")
|
||||||
parser.add_argument("--backup-root", required=True)
|
parser.add_argument("--backup-root", required=True)
|
||||||
parser.add_argument("--pobsync-home", default=settings.POBSYNC_HOME)
|
|
||||||
parser.add_argument("--ssh-user", default="root")
|
parser.add_argument("--ssh-user", default="root")
|
||||||
parser.add_argument("--ssh-port", type=int, default=22)
|
parser.add_argument("--ssh-port", type=int, default=22)
|
||||||
parser.add_argument("--source-root", default="/")
|
parser.add_argument("--source-root", default="/")
|
||||||
@@ -30,11 +27,9 @@ class Command(BaseCommand):
|
|||||||
if not is_absolute_non_root(backup_root):
|
if not is_absolute_non_root(backup_root):
|
||||||
raise CommandError("--backup-root must be an absolute path and must not be '/'")
|
raise CommandError("--backup-root must be an absolute path and must not be '/'")
|
||||||
|
|
||||||
pobsync_home = str(Path(options["pobsync_home"]))
|
|
||||||
retention = parse_retention(options["retention"])
|
retention = parse_retention(options["retention"])
|
||||||
defaults = {
|
defaults = {
|
||||||
"backup_root": backup_root,
|
"backup_root": backup_root,
|
||||||
"pobsync_home": pobsync_home,
|
|
||||||
"ssh_user": options["ssh_user"],
|
"ssh_user": options["ssh_user"],
|
||||||
"ssh_port": options["ssh_port"],
|
"ssh_port": options["ssh_port"],
|
||||||
"ssh_options": ["-oBatchMode=yes", "-oStrictHostKeyChecking=accept-new"],
|
"ssh_options": ["-oBatchMode=yes", "-oStrictHostKeyChecking=accept-new"],
|
||||||
@@ -53,8 +48,8 @@ class Command(BaseCommand):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if GlobalConfig.objects.filter(name=options["name"]).exists() and not options["force"]:
|
if GlobalConfig.objects.filter(name=options["name"]).exists() and not options["force"]:
|
||||||
raise CommandError(f"GlobalConfig {options['name']!r} already exists; use --force to update")
|
raise CommandError(f"Global config {options['name']!r} already exists; use --force to update")
|
||||||
|
|
||||||
_obj, created = GlobalConfig.objects.update_or_create(name=options["name"], defaults=defaults)
|
_obj, created = GlobalConfig.objects.update_or_create(name=options["name"], defaults=defaults)
|
||||||
action = "Created" if created else "Updated"
|
action = "Created" if created else "Updated"
|
||||||
self.stdout.write(self.style.SUCCESS(f"{action} GlobalConfig {options['name']!r}."))
|
self.stdout.write(self.style.SUCCESS(f"{action} global config {options['name']!r}."))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pobsync_backend.models import GlobalConfig, HostConfig
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Create or update a SQL-backed host pobsync configuration."
|
help = "Create or update a host backup configuration."
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
parser.add_argument("host")
|
parser.add_argument("host")
|
||||||
@@ -29,7 +29,7 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args: Any, **options: Any) -> None:
|
def handle(self, *args: Any, **options: Any) -> None:
|
||||||
host = sanitize_host(options["host"])
|
host = sanitize_host(options["host"])
|
||||||
if HostConfig.objects.filter(host=host).exists() and not options["force"]:
|
if HostConfig.objects.filter(host=host).exists() and not options["force"]:
|
||||||
raise CommandError(f"HostConfig {host!r} already exists; use --force to update")
|
raise CommandError(f"Host {host!r} already exists; use --force to update")
|
||||||
|
|
||||||
retention = self._retention(options["retention"])
|
retention = self._retention(options["retention"])
|
||||||
defaults = {
|
defaults = {
|
||||||
@@ -49,7 +49,7 @@ class Command(BaseCommand):
|
|||||||
}
|
}
|
||||||
_obj, created = HostConfig.objects.update_or_create(host=host, defaults=defaults)
|
_obj, created = HostConfig.objects.update_or_create(host=host, defaults=defaults)
|
||||||
action = "Created" if created else "Updated"
|
action = "Created" if created else "Updated"
|
||||||
self.stdout.write(self.style.SUCCESS(f"{action} HostConfig {host!r}."))
|
self.stdout.write(self.style.SUCCESS(f"{action} host {host!r}."))
|
||||||
|
|
||||||
def _retention(self, value: str | None) -> dict[str, int]:
|
def _retention(self, value: str | None) -> dict[str, int]:
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ from pobsync_backend.scheduler import parse_cron_expr
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Create, update, disable, or remove a SQL-backed pobsync schedule."
|
help = "Create, update, disable, or remove a scheduler-managed host schedule."
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
parser.add_argument("host")
|
parser.add_argument("host")
|
||||||
parser.add_argument("--cron", help='Cron expression, e.g. "15 2 * * *"')
|
parser.add_argument(
|
||||||
parser.add_argument("--user", default="root")
|
"--schedule-expression",
|
||||||
|
"--cron",
|
||||||
|
dest="schedule_expression",
|
||||||
|
help='Five-field schedule expression, e.g. "15 2 * * *"',
|
||||||
|
)
|
||||||
parser.add_argument("--prune", action="store_true")
|
parser.add_argument("--prune", action="store_true")
|
||||||
parser.add_argument("--prune-max-delete", type=int, default=10)
|
parser.add_argument("--prune-max-delete", type=int, default=10)
|
||||||
parser.add_argument("--prune-protect-bases", action="store_true")
|
parser.add_argument("--prune-protect-bases", action="store_true")
|
||||||
@@ -25,25 +29,25 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
host = HostConfig.objects.get(host=options["host"])
|
host = HostConfig.objects.get(host=options["host"])
|
||||||
except HostConfig.DoesNotExist as exc:
|
except HostConfig.DoesNotExist as exc:
|
||||||
raise CommandError(f"Missing HostConfig {options['host']!r}") from exc
|
raise CommandError(f"Missing host {options['host']!r}") from exc
|
||||||
|
|
||||||
if options["delete"]:
|
if options["delete"]:
|
||||||
deleted, _details = ScheduleConfig.objects.filter(host=host).delete()
|
deleted, _details = ScheduleConfig.objects.filter(host=host).delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} schedule row(s) for {host.host!r}."))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} schedule row(s) for {host.host!r}."))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not options["cron"]:
|
schedule_expression = options["schedule_expression"]
|
||||||
raise CommandError("--cron is required unless --delete is used")
|
if not schedule_expression:
|
||||||
|
raise CommandError("--schedule-expression is required unless --delete is used")
|
||||||
try:
|
try:
|
||||||
parse_cron_expr(options["cron"])
|
parse_cron_expr(schedule_expression)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise CommandError(str(exc)) from exc
|
raise CommandError(str(exc)) from exc
|
||||||
|
|
||||||
schedule, created = ScheduleConfig.objects.update_or_create(
|
schedule, created = ScheduleConfig.objects.update_or_create(
|
||||||
host=host,
|
host=host,
|
||||||
defaults={
|
defaults={
|
||||||
"cron_expr": options["cron"],
|
"cron_expr": schedule_expression,
|
||||||
"user": options["user"],
|
|
||||||
"enabled": not options["disabled"],
|
"enabled": not options["disabled"],
|
||||||
"prune": bool(options["prune"]),
|
"prune": bool(options["prune"]),
|
||||||
"prune_max_delete": int(options["prune_max_delete"]),
|
"prune_max_delete": int(options["prune_max_delete"]),
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
global_config = GlobalConfig.objects.get(name="default")
|
global_config = GlobalConfig.objects.get(name="default")
|
||||||
except GlobalConfig.DoesNotExist as exc:
|
except GlobalConfig.DoesNotExist as exc:
|
||||||
raise CommandError("Missing GlobalConfig 'default'") from exc
|
raise CommandError("Missing default global config") from exc
|
||||||
|
|
||||||
host = None
|
host = None
|
||||||
if options["host"]:
|
if options["host"]:
|
||||||
try:
|
try:
|
||||||
host = HostConfig.objects.get(host=options["host"], enabled=True)
|
host = HostConfig.objects.get(host=options["host"], enabled=True)
|
||||||
except HostConfig.DoesNotExist as exc:
|
except HostConfig.DoesNotExist as exc:
|
||||||
raise CommandError(f"Missing enabled HostConfig {options['host']!r}") from exc
|
raise CommandError(f"Missing enabled host {options['host']!r}") from exc
|
||||||
|
|
||||||
kind = normalize_kind(options["kind"])
|
kind = normalize_kind(options["kind"])
|
||||||
kinds = ["scheduled", "manual", "incomplete"] if kind == "all" else [kind]
|
kinds = ["scheduled", "manual", "incomplete"] if kind == "all" else [kind]
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from pobsync_backend.models import GlobalConfig, SshCredential
|
||||||
|
from pobsync_backend.ssh_keys import SshKeyError, generate_ssh_key
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Ensure a filesystem-backed SSH key exists for pobsync backups."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--name", default="default", help="Credential name to create or reuse.")
|
||||||
|
parser.add_argument("--key-type", default="ed25519", choices=("ed25519", "rsa"))
|
||||||
|
parser.add_argument(
|
||||||
|
"--set-global-default",
|
||||||
|
action="store_true",
|
||||||
|
help="Set this key as default on the default global config when it exists.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
name = options["name"]
|
||||||
|
credential, created = SshCredential.objects.get_or_create(
|
||||||
|
name=name,
|
||||||
|
defaults={
|
||||||
|
"key_type": options["key_type"],
|
||||||
|
"notes": "Generated by pobsync installer.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not credential.key_path and not credential.private_key:
|
||||||
|
try:
|
||||||
|
generate_ssh_key(credential, key_type=options["key_type"])
|
||||||
|
except SshKeyError as exc:
|
||||||
|
raise CommandError(str(exc)) from exc
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if options["set_global_default"]:
|
||||||
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||||
|
if global_config is not None and global_config.default_ssh_credential_id is None:
|
||||||
|
global_config.default_ssh_credential = credential
|
||||||
|
global_config.save(update_fields=["default_ssh_credential", "updated_at"])
|
||||||
|
|
||||||
|
action = "created" if created else "exists"
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"SSH credential {action}: {credential.name}"))
|
||||||
|
if credential.public_key:
|
||||||
|
self.stdout.write(credential.public_key)
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from pobsync_backend.config_repository import export_runtime_configs
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Export Django database configs to pobsync runtime YAML files."
|
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
|
||||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
|
|
||||||
parser.add_argument("--host", default=None, help="Export only one enabled host")
|
|
||||||
|
|
||||||
def handle(self, *args: Any, **options: Any) -> None:
|
|
||||||
written = export_runtime_configs(prefix=Path(options["prefix"]), host=options["host"])
|
|
||||||
for path in written:
|
|
||||||
self.stdout.write(str(path))
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Exported {len(written)} config file(s)."))
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
|
|
||||||
from pobsync.config.load import load_global_config, load_host_config
|
|
||||||
from pobsync.paths import PobsyncPaths
|
|
||||||
from pobsync_backend.models import GlobalConfig, HostConfig
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Import pobsync YAML configs into the Django database."
|
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
|
||||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
|
|
||||||
|
|
||||||
def handle(self, *args: Any, **options: Any) -> None:
|
|
||||||
paths = PobsyncPaths(home=Path(options["prefix"]))
|
|
||||||
if not paths.global_config_path.exists():
|
|
||||||
raise CommandError(f"Missing global config: {paths.global_config_path}")
|
|
||||||
|
|
||||||
global_cfg = load_global_config(paths.global_config_path)
|
|
||||||
global_ssh = global_cfg.get("ssh") or {}
|
|
||||||
global_rsync = global_cfg.get("rsync") or {}
|
|
||||||
global_defaults = global_cfg.get("defaults") or {}
|
|
||||||
retention_defaults = global_cfg.get("retention_defaults") or {}
|
|
||||||
GlobalConfig.objects.update_or_create(
|
|
||||||
name="default",
|
|
||||||
defaults={
|
|
||||||
"backup_root": global_cfg["backup_root"],
|
|
||||||
"pobsync_home": global_cfg.get("pobsync_home", str(paths.home)),
|
|
||||||
"ssh_user": global_ssh.get("user") or "root",
|
|
||||||
"ssh_port": global_ssh.get("port") or 22,
|
|
||||||
"ssh_options": global_ssh.get("options") or [],
|
|
||||||
"rsync_binary": global_rsync.get("binary") or "rsync",
|
|
||||||
"rsync_args": global_rsync.get("args") or [],
|
|
||||||
"rsync_extra_args": global_rsync.get("extra_args") or [],
|
|
||||||
"rsync_timeout_seconds": global_rsync.get("timeout_seconds") or 0,
|
|
||||||
"rsync_bwlimit_kbps": global_rsync.get("bwlimit_kbps") or 0,
|
|
||||||
"default_source_root": global_defaults.get("source_root") or "/",
|
|
||||||
"default_destination_subdir": global_defaults.get("destination_subdir") or "",
|
|
||||||
"excludes_default": global_cfg.get("excludes_default") or [],
|
|
||||||
"retention_daily": retention_defaults.get("daily", 14),
|
|
||||||
"retention_weekly": retention_defaults.get("weekly", 8),
|
|
||||||
"retention_monthly": retention_defaults.get("monthly", 12),
|
|
||||||
"retention_yearly": retention_defaults.get("yearly", 0),
|
|
||||||
"data": global_cfg,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for host_path in sorted(paths.hosts_dir.glob("*.yaml")):
|
|
||||||
host_cfg = load_host_config(host_path)
|
|
||||||
host_ssh = host_cfg.get("ssh") or {}
|
|
||||||
host_rsync = host_cfg.get("rsync") or {}
|
|
||||||
host_retention = host_cfg.get("retention") or {}
|
|
||||||
HostConfig.objects.update_or_create(
|
|
||||||
host=host_cfg["host"],
|
|
||||||
defaults={
|
|
||||||
"address": host_cfg["address"],
|
|
||||||
"ssh_user": host_ssh.get("user") or "",
|
|
||||||
"ssh_port": host_ssh.get("port"),
|
|
||||||
"source_root": host_cfg.get("source_root") or "",
|
|
||||||
"includes": host_cfg.get("includes") or [],
|
|
||||||
"excludes_add": host_cfg.get("excludes_add") or [],
|
|
||||||
"excludes_replace": host_cfg.get("excludes_replace"),
|
|
||||||
"rsync_extra_args": host_rsync.get("extra_args") or [],
|
|
||||||
"retention_daily": host_retention.get("daily", 14),
|
|
||||||
"retention_weekly": host_retention.get("weekly", 8),
|
|
||||||
"retention_monthly": host_retention.get("monthly", 12),
|
|
||||||
"retention_yearly": host_retention.get("yearly", 0),
|
|
||||||
"config": host_cfg,
|
|
||||||
"enabled": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Imported global config and {count} host config(s)."))
|
|
||||||
@@ -5,25 +5,24 @@ from typing import Any
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from pobsync.commands.run_scheduled import run_scheduled
|
|
||||||
from pobsync.paths import PobsyncPaths
|
from pobsync.paths import PobsyncPaths
|
||||||
from pobsync_backend.config_source import DjangoConfigSource
|
from pobsync_backend.backup_runner import execute_backup_run
|
||||||
from pobsync_backend.models import BackupRun, HostConfig
|
from pobsync_backend.models import BackupRun, HostConfig
|
||||||
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Run a scheduled pobsync backup and record the result in Django."
|
help = "Run a pobsync backup and record the result in Django."
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
parser.add_argument("host", help="Host to back up")
|
parser.add_argument("host", help="Host to back up")
|
||||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
|
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run")
|
parser.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run")
|
||||||
|
parser.add_argument("--verbose-rsync", action="store_true", help="Write itemized rsync output to the run log")
|
||||||
parser.add_argument("--prune", action="store_true", help="Apply retention after a successful run")
|
parser.add_argument("--prune", action="store_true", help="Apply retention after a successful run")
|
||||||
parser.add_argument("--prune-max-delete", type=int, default=10)
|
parser.add_argument("--prune-max-delete", type=int, default=10)
|
||||||
parser.add_argument("--prune-protect-bases", action="store_true")
|
parser.add_argument("--prune-protect-bases", action="store_true")
|
||||||
|
parser.add_argument("--manual", action="store_true", help="Record the run as manual instead of scheduled")
|
||||||
|
|
||||||
def handle(self, *args: Any, **options: Any) -> None:
|
def handle(self, *args: Any, **options: Any) -> None:
|
||||||
host_name = options["host"]
|
host_name = options["host"]
|
||||||
@@ -31,59 +30,38 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
host = HostConfig.objects.get(host=host_name, enabled=True)
|
host = HostConfig.objects.get(host=host_name, enabled=True)
|
||||||
except HostConfig.DoesNotExist as exc:
|
except HostConfig.DoesNotExist as exc:
|
||||||
raise CommandError(f"Missing enabled HostConfig {host_name!r}") from exc
|
raise CommandError(f"Missing enabled host {host_name!r}") from exc
|
||||||
|
|
||||||
run = BackupRun.objects.create(
|
run = BackupRun.objects.create(
|
||||||
host=host,
|
host=host,
|
||||||
run_type=BackupRun.RunType.SCHEDULED,
|
run_type=BackupRun.RunType.MANUAL if options["manual"] else BackupRun.RunType.SCHEDULED,
|
||||||
status=BackupRun.Status.RUNNING,
|
status=BackupRun.Status.RUNNING,
|
||||||
started_at=timezone.now(),
|
result={
|
||||||
|
"requested": {
|
||||||
|
"dry_run": bool(options["dry_run"]),
|
||||||
|
"verbose_output": bool(options["dry_run"] or options["verbose_rsync"]),
|
||||||
|
"prune": bool(options["prune"]),
|
||||||
|
"prune_max_delete": int(options["prune_max_delete"]),
|
||||||
|
"prune_protect_bases": bool(options["prune_protect_bases"]),
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
execute_backup_run(
|
||||||
try:
|
run=run,
|
||||||
result = run_scheduled(
|
|
||||||
prefix=paths.home,
|
prefix=paths.home,
|
||||||
host=host.host,
|
|
||||||
dry_run=bool(options["dry_run"]),
|
dry_run=bool(options["dry_run"]),
|
||||||
|
verbose_output=bool(options["dry_run"] or options["verbose_rsync"]),
|
||||||
prune=bool(options["prune"]),
|
prune=bool(options["prune"]),
|
||||||
prune_max_delete=int(options["prune_max_delete"]),
|
prune_max_delete=int(options["prune_max_delete"]),
|
||||||
prune_protect_bases=bool(options["prune_protect_bases"]),
|
prune_protect_bases=bool(options["prune_protect_bases"]),
|
||||||
config_source=DjangoConfigSource(),
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
run.refresh_from_db()
|
||||||
run.status = BackupRun.Status.FAILED
|
|
||||||
run.ended_at = timezone.now()
|
|
||||||
run.result = {"ok": False, "error": str(exc), "type": type(exc).__name__}
|
|
||||||
run.save(update_fields=["status", "ended_at", "result"])
|
|
||||||
raise
|
|
||||||
|
|
||||||
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
|
if run.status == BackupRun.Status.SUCCESS:
|
||||||
run.ended_at = timezone.now()
|
|
||||||
run.snapshot_path = str(result.get("snapshot") or "")
|
|
||||||
run.base_path = str(result.get("base") or "")
|
|
||||||
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
|
|
||||||
run.rsync_exit_code = rsync.get("exit_code")
|
|
||||||
run.result = result
|
|
||||||
run.save(
|
|
||||||
update_fields=[
|
|
||||||
"status",
|
|
||||||
"ended_at",
|
|
||||||
"snapshot_path",
|
|
||||||
"base_path",
|
|
||||||
"rsync_exit_code",
|
|
||||||
"result",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
if run.snapshot_path:
|
|
||||||
snapshot_path = Path(run.snapshot_path)
|
|
||||||
try:
|
|
||||||
kind = infer_snapshot_kind(snapshot_path)
|
|
||||||
upsert_snapshot_record(host=host, kind=kind, snapshot_dir=snapshot_path)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if result.get("ok"):
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}."))
|
self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}."))
|
||||||
return
|
return
|
||||||
|
if run.status == BackupRun.Status.WARNING:
|
||||||
|
self.stdout.write(self.style.WARNING(f"Backup completed with warnings for {host.host}; run id={run.id}"))
|
||||||
|
return
|
||||||
|
|
||||||
raise CommandError(f"Backup failed for {host.host}; run id={run.id}")
|
raise CommandError(f"Backup failed for {host.host}; run id={run.id}")
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ from typing import Any
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
from pobsync.commands.retention_apply import run_retention_apply
|
from pobsync.errors import ConfigError
|
||||||
from pobsync.commands.retention_plan import run_retention_plan
|
from pobsync_backend.retention import run_sql_retention_apply, run_sql_retention_plan
|
||||||
from pobsync_backend.config_source import DjangoConfigSource
|
|
||||||
from pobsync_backend.models import HostConfig
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Plan or apply retention using SQL-backed pobsync configuration."
|
help = "Plan or apply retention using the Django backup configuration."
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
parser.add_argument("host")
|
parser.add_argument("host")
|
||||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME)
|
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
|
||||||
parser.add_argument("--kind", default="scheduled", choices=["scheduled", "manual", "all"])
|
parser.add_argument("--kind", default="scheduled", choices=["scheduled", "manual", "all"])
|
||||||
parser.add_argument("--protect-bases", action="store_true")
|
parser.add_argument("--protect-bases", action="store_true")
|
||||||
parser.add_argument("--apply", action="store_true")
|
parser.add_argument("--apply", action="store_true")
|
||||||
@@ -27,29 +25,26 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args: Any, **options: Any) -> None:
|
def handle(self, *args: Any, **options: Any) -> None:
|
||||||
host = options["host"]
|
host = options["host"]
|
||||||
if not HostConfig.objects.filter(host=host, enabled=True).exists():
|
try:
|
||||||
raise CommandError(f"Missing enabled HostConfig {host!r}")
|
|
||||||
|
|
||||||
config_source = DjangoConfigSource()
|
|
||||||
if options["apply"]:
|
if options["apply"]:
|
||||||
if not options["yes"]:
|
if not options["yes"]:
|
||||||
raise CommandError("--yes is required with --apply")
|
raise CommandError("--yes is required with --apply")
|
||||||
result = run_retention_apply(
|
result = run_sql_retention_apply(
|
||||||
prefix=Path(options["prefix"]),
|
prefix=Path(options["prefix"]),
|
||||||
host=host,
|
host=host,
|
||||||
kind=options["kind"],
|
kind=options["kind"],
|
||||||
protect_bases=bool(options["protect_bases"]),
|
protect_bases=bool(options["protect_bases"]),
|
||||||
yes=True,
|
yes=True,
|
||||||
max_delete=int(options["max_delete"]),
|
max_delete=int(options["max_delete"]),
|
||||||
config_source=config_source,
|
action="cli",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = run_retention_plan(
|
result = run_sql_retention_plan(
|
||||||
prefix=Path(options["prefix"]),
|
|
||||||
host=host,
|
host=host,
|
||||||
kind=options["kind"],
|
kind=options["kind"],
|
||||||
protect_bases=bool(options["protect_bases"]),
|
protect_bases=bool(options["protect_bases"]),
|
||||||
config_source=config_source,
|
|
||||||
)
|
)
|
||||||
|
except ConfigError as exc:
|
||||||
|
raise CommandError(str(exc)) from exc
|
||||||
|
|
||||||
self.stdout.write(json.dumps(result, indent=2, sort_keys=False))
|
self.stdout.write(json.dumps(result, indent=2, sort_keys=False))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.core.management.base import BaseCommand
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from pobsync_backend.models import ScheduleConfig
|
from pobsync_backend.models import BackupRun, ScheduleConfig
|
||||||
from pobsync_backend.scheduler import due_key, is_due
|
from pobsync_backend.scheduler import due_key, is_due
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ class Command(BaseCommand):
|
|||||||
help = "Run due pobsync schedules from the Django database."
|
help = "Run due pobsync schedules from the Django database."
|
||||||
|
|
||||||
def add_arguments(self, parser) -> None:
|
def add_arguments(self, parser) -> None:
|
||||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
|
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
|
||||||
parser.add_argument("--once", action="store_true", help="Check once and exit")
|
parser.add_argument("--once", action="store_true", help="Check once and exit")
|
||||||
parser.add_argument("--loop", action="store_true", help="Keep checking schedules")
|
parser.add_argument("--loop", action="store_true", help="Keep checking schedules")
|
||||||
parser.add_argument("--interval", type=int, default=60, help="Loop interval in seconds")
|
parser.add_argument("--interval", type=int, default=60, help="Loop interval in seconds")
|
||||||
@@ -52,12 +52,13 @@ class Command(BaseCommand):
|
|||||||
if not is_due(schedule.cron_expr, now):
|
if not is_due(schedule.cron_expr, now):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
schedule_started_at = timezone.now()
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
locked = ScheduleConfig.objects.select_for_update().get(pk=schedule.pk)
|
locked = ScheduleConfig.objects.select_for_update().get(pk=schedule.pk)
|
||||||
if locked.last_due_key == current_due_key:
|
if locked.last_due_key == current_due_key:
|
||||||
continue
|
continue
|
||||||
locked.last_due_key = current_due_key
|
locked.last_due_key = current_due_key
|
||||||
locked.last_started_at = timezone.now()
|
locked.last_started_at = schedule_started_at
|
||||||
locked.last_status = "running"
|
locked.last_status = "running"
|
||||||
locked.save(update_fields=["last_due_key", "last_started_at", "last_status", "updated_at"])
|
locked.save(update_fields=["last_due_key", "last_started_at", "last_status", "updated_at"])
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ class Command(BaseCommand):
|
|||||||
prune_max_delete=schedule.prune_max_delete,
|
prune_max_delete=schedule.prune_max_delete,
|
||||||
prune_protect_bases=schedule.prune_protect_bases,
|
prune_protect_bases=schedule.prune_protect_bases,
|
||||||
)
|
)
|
||||||
|
status = _latest_scheduled_run_status(host_id=schedule.host_id, started_at=schedule_started_at) or status
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
self.stderr.write(f"{schedule.host.host}: {type(exc).__name__}: {exc}")
|
self.stderr.write(f"{schedule.host.host}: {type(exc).__name__}: {exc}")
|
||||||
@@ -83,3 +85,16 @@ class Command(BaseCommand):
|
|||||||
ran += 1
|
ran += 1
|
||||||
|
|
||||||
return ran
|
return ran
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_scheduled_run_status(*, host_id: int, started_at) -> str | None:
|
||||||
|
run = (
|
||||||
|
BackupRun.objects.filter(
|
||||||
|
host_id=host_id,
|
||||||
|
run_type=BackupRun.RunType.SCHEDULED,
|
||||||
|
created_at__gte=started_at,
|
||||||
|
)
|
||||||
|
.order_by("-created_at", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return run.status if run is not None else None
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from pobsync.paths import PobsyncPaths
|
||||||
|
from pobsync_backend.backup_runner import claim_next_queued_run, execute_backup_run, reconcile_running_runs, requested_options
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Run queued pobsync backup jobs from the Django database."
|
||||||
|
|
||||||
|
def add_arguments(self, parser) -> None:
|
||||||
|
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
|
||||||
|
parser.add_argument("--once", action="store_true", help="Process one queued run and exit")
|
||||||
|
parser.add_argument("--loop", action="store_true", help="Keep checking for queued runs")
|
||||||
|
parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds")
|
||||||
|
parser.add_argument(
|
||||||
|
"--stale-running-seconds",
|
||||||
|
type=int,
|
||||||
|
default=24 * 60 * 60,
|
||||||
|
help="Mark running runs failed after this many seconds without a worker heartbeat; use 0 to disable",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args: Any, **options: Any) -> None:
|
||||||
|
if not options["once"] and not options["loop"]:
|
||||||
|
options["once"] = True
|
||||||
|
|
||||||
|
paths = PobsyncPaths(home=Path(options["prefix"]))
|
||||||
|
while True:
|
||||||
|
count = self._run_once(prefix=paths.home, stale_running_seconds=int(options["stale_running_seconds"]))
|
||||||
|
self.stdout.write(f"Ran {count} queued backup run(s).")
|
||||||
|
if options["once"]:
|
||||||
|
return
|
||||||
|
time.sleep(max(1, int(options["interval"])))
|
||||||
|
|
||||||
|
def _run_once(self, *, prefix: Path, stale_running_seconds: int = 24 * 60 * 60) -> int:
|
||||||
|
reconciled = reconcile_running_runs(stale_worker_seconds=stale_running_seconds)
|
||||||
|
run = claim_next_queued_run()
|
||||||
|
if run is None:
|
||||||
|
return reconciled
|
||||||
|
|
||||||
|
options = requested_options(run)
|
||||||
|
try:
|
||||||
|
execute_backup_run(
|
||||||
|
run=run,
|
||||||
|
prefix=prefix,
|
||||||
|
dry_run=bool(options.get("dry_run", False)),
|
||||||
|
verbose_output=bool(options.get("verbose_output", False)),
|
||||||
|
prune=bool(options.get("prune", False)),
|
||||||
|
prune_max_delete=int(options.get("prune_max_delete", 10)),
|
||||||
|
prune_protect_bases=bool(options.get("prune_protect_bases", False)),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.stderr.write(f"{run.host.host}: {type(exc).__name__}: {exc}")
|
||||||
|
return reconciled + 1
|
||||||
24
src/pobsync_backend/migrations/0004_backuprun_snapshot.py
Normal file
24
src/pobsync_backend/migrations/0004_backuprun_snapshot.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0003_structured_config_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="backuprun",
|
||||||
|
name="snapshot",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="backup_runs",
|
||||||
|
to="pobsync_backend.snapshotrecord",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
44
src/pobsync_backend/migrations/0005_snapshotrecord_base.py
Normal file
44
src/pobsync_backend/migrations/0005_snapshotrecord_base.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0004_backuprun_snapshot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="snapshotrecord",
|
||||||
|
name="base",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="derived_snapshots",
|
||||||
|
to="pobsync_backend.snapshotrecord",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="snapshotrecord",
|
||||||
|
name="base_dirname",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="snapshotrecord",
|
||||||
|
name="base_snapshot_id",
|
||||||
|
field=models.CharField(blank=True, max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="snapshotrecord",
|
||||||
|
name="base_kind",
|
||||||
|
field=models.CharField(blank=True, max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="snapshotrecord",
|
||||||
|
name="base_path",
|
||||||
|
field=models.CharField(blank=True, max_length=1024),
|
||||||
|
),
|
||||||
|
]
|
||||||
51
src/pobsync_backend/migrations/0006_ssh_credentials.py
Normal file
51
src/pobsync_backend/migrations/0006_ssh_credentials.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-05-19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0005_snapshotrecord_base"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SshCredential",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("name", models.CharField(max_length=128, unique=True)),
|
||||||
|
("private_key", models.TextField()),
|
||||||
|
("public_key", models.TextField(blank=True)),
|
||||||
|
("known_hosts", models.TextField(blank=True)),
|
||||||
|
("notes", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="globalconfig",
|
||||||
|
name="default_ssh_credential",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="global_configs",
|
||||||
|
to="pobsync_backend.sshcredential",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="hostconfig",
|
||||||
|
name="ssh_credential",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="hosts",
|
||||||
|
to="pobsync_backend.sshcredential",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0006_ssh_credentials"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sshcredential",
|
||||||
|
name="private_key",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sshcredential",
|
||||||
|
name="key_path",
|
||||||
|
field=models.CharField(blank=True, max_length=1024),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sshcredential",
|
||||||
|
name="key_type",
|
||||||
|
field=models.CharField(default="ed25519", max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sshcredential",
|
||||||
|
name="fingerprint",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sshcredential",
|
||||||
|
name="generated",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0007_filesystem_ssh_credentials"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="backuprun",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("queued", "Queued"),
|
||||||
|
("running", "Running"),
|
||||||
|
("success", "Success"),
|
||||||
|
("warning", "Warning"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("cancelled", "Cancelled"),
|
||||||
|
],
|
||||||
|
default="queued",
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0008_alter_backuprun_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scheduleconfig",
|
||||||
|
name="user",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0009_remove_scheduleconfig_user"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="globalconfig",
|
||||||
|
name="pobsync_home",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0010_remove_globalconfig_pobsync_home"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="globalconfig",
|
||||||
|
name="data",
|
||||||
|
),
|
||||||
|
]
|
||||||
30
src/pobsync_backend/migrations/0012_review_state.py
Normal file
30
src/pobsync_backend/migrations/0012_review_state.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0011_remove_globalconfig_data"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="backuprun",
|
||||||
|
name="reviewed_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="backuprun",
|
||||||
|
name="reviewed_by",
|
||||||
|
field=models.CharField(blank=True, max_length=150),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="snapshotrecord",
|
||||||
|
name="reviewed_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="snapshotrecord",
|
||||||
|
name="reviewed_by",
|
||||||
|
field=models.CharField(blank=True, max_length=150),
|
||||||
|
),
|
||||||
|
]
|
||||||
50
src/pobsync_backend/migrations/0013_purgedsnapshot.py
Normal file
50
src/pobsync_backend/migrations/0013_purgedsnapshot.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pobsync_backend", "0012_review_state"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PurgedSnapshot",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("host_name", models.CharField(max_length=255)),
|
||||||
|
("kind", models.CharField(max_length=16)),
|
||||||
|
("dirname", models.CharField(max_length=255)),
|
||||||
|
("path", models.CharField(max_length=1024)),
|
||||||
|
("reason", models.CharField(blank=True, max_length=512)),
|
||||||
|
(
|
||||||
|
"action",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("manual", "Manual"),
|
||||||
|
("scheduled", "Scheduled"),
|
||||||
|
("cli", "CLI"),
|
||||||
|
("incomplete_cleanup", "Incomplete cleanup"),
|
||||||
|
],
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("triggered_by", models.CharField(blank=True, max_length=150)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("purged_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"host",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="purged_snapshots",
|
||||||
|
to="pobsync_backend.hostconfig",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-purged_at", "host_name", "dirname"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -14,7 +14,13 @@ class TimestampedModel(models.Model):
|
|||||||
class GlobalConfig(TimestampedModel):
|
class GlobalConfig(TimestampedModel):
|
||||||
name = models.CharField(max_length=64, default="default", unique=True)
|
name = models.CharField(max_length=64, default="default", unique=True)
|
||||||
backup_root = models.CharField(max_length=512)
|
backup_root = models.CharField(max_length=512)
|
||||||
pobsync_home = models.CharField(max_length=512, default="/opt/pobsync")
|
default_ssh_credential = models.ForeignKey(
|
||||||
|
"SshCredential",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="global_configs",
|
||||||
|
)
|
||||||
ssh_user = models.CharField(max_length=64, default="root")
|
ssh_user = models.CharField(max_length=64, default="root")
|
||||||
ssh_port = models.PositiveIntegerField(default=22)
|
ssh_port = models.PositiveIntegerField(default=22)
|
||||||
ssh_options = models.JSONField(default=list, blank=True)
|
ssh_options = models.JSONField(default=list, blank=True)
|
||||||
@@ -30,7 +36,6 @@ class GlobalConfig(TimestampedModel):
|
|||||||
retention_weekly = models.PositiveIntegerField(default=8)
|
retention_weekly = models.PositiveIntegerField(default=8)
|
||||||
retention_monthly = models.PositiveIntegerField(default=12)
|
retention_monthly = models.PositiveIntegerField(default=12)
|
||||||
retention_yearly = models.PositiveIntegerField(default=0)
|
retention_yearly = models.PositiveIntegerField(default=0)
|
||||||
data = models.JSONField(default=dict, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "global config"
|
verbose_name = "global config"
|
||||||
@@ -44,6 +49,13 @@ class HostConfig(TimestampedModel):
|
|||||||
host = models.CharField(max_length=255, unique=True)
|
host = models.CharField(max_length=255, unique=True)
|
||||||
address = models.CharField(max_length=255)
|
address = models.CharField(max_length=255)
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
|
ssh_credential = models.ForeignKey(
|
||||||
|
"SshCredential",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="hosts",
|
||||||
|
)
|
||||||
ssh_user = models.CharField(max_length=64, blank=True)
|
ssh_user = models.CharField(max_length=64, blank=True)
|
||||||
ssh_port = models.PositiveIntegerField(null=True, blank=True)
|
ssh_port = models.PositiveIntegerField(null=True, blank=True)
|
||||||
source_root = models.CharField(max_length=512, blank=True)
|
source_root = models.CharField(max_length=512, blank=True)
|
||||||
@@ -64,6 +76,24 @@ class HostConfig(TimestampedModel):
|
|||||||
return self.host
|
return self.host
|
||||||
|
|
||||||
|
|
||||||
|
class SshCredential(TimestampedModel):
|
||||||
|
name = models.CharField(max_length=128, unique=True)
|
||||||
|
private_key = models.TextField(blank=True, default="")
|
||||||
|
public_key = models.TextField(blank=True)
|
||||||
|
key_path = models.CharField(max_length=1024, blank=True)
|
||||||
|
key_type = models.CharField(max_length=32, default="ed25519")
|
||||||
|
fingerprint = models.CharField(max_length=255, blank=True)
|
||||||
|
generated = models.BooleanField(default=False)
|
||||||
|
known_hosts = models.TextField(blank=True)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class BackupRun(models.Model):
|
class BackupRun(models.Model):
|
||||||
class RunType(models.TextChoices):
|
class RunType(models.TextChoices):
|
||||||
SCHEDULED = "scheduled", "Scheduled"
|
SCHEDULED = "scheduled", "Scheduled"
|
||||||
@@ -73,6 +103,7 @@ class BackupRun(models.Model):
|
|||||||
QUEUED = "queued", "Queued"
|
QUEUED = "queued", "Queued"
|
||||||
RUNNING = "running", "Running"
|
RUNNING = "running", "Running"
|
||||||
SUCCESS = "success", "Success"
|
SUCCESS = "success", "Success"
|
||||||
|
WARNING = "warning", "Warning"
|
||||||
FAILED = "failed", "Failed"
|
FAILED = "failed", "Failed"
|
||||||
CANCELLED = "cancelled", "Cancelled"
|
CANCELLED = "cancelled", "Cancelled"
|
||||||
|
|
||||||
@@ -82,10 +113,19 @@ class BackupRun(models.Model):
|
|||||||
started_at = models.DateTimeField(null=True, blank=True)
|
started_at = models.DateTimeField(null=True, blank=True)
|
||||||
ended_at = models.DateTimeField(null=True, blank=True)
|
ended_at = models.DateTimeField(null=True, blank=True)
|
||||||
snapshot_path = models.CharField(max_length=1024, blank=True)
|
snapshot_path = models.CharField(max_length=1024, blank=True)
|
||||||
|
snapshot = models.ForeignKey(
|
||||||
|
"SnapshotRecord",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="backup_runs",
|
||||||
|
)
|
||||||
base_path = models.CharField(max_length=1024, blank=True)
|
base_path = models.CharField(max_length=1024, blank=True)
|
||||||
rsync_exit_code = models.IntegerField(null=True, blank=True)
|
rsync_exit_code = models.IntegerField(null=True, blank=True)
|
||||||
result = models.JSONField(default=dict, blank=True)
|
result = models.JSONField(default=dict, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
reviewed_by = models.CharField(max_length=150, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
@@ -104,11 +144,24 @@ class SnapshotRecord(models.Model):
|
|||||||
kind = models.CharField(max_length=16, choices=Kind.choices)
|
kind = models.CharField(max_length=16, choices=Kind.choices)
|
||||||
dirname = models.CharField(max_length=255)
|
dirname = models.CharField(max_length=255)
|
||||||
path = models.CharField(max_length=1024)
|
path = models.CharField(max_length=1024)
|
||||||
|
base = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="derived_snapshots",
|
||||||
|
)
|
||||||
|
base_kind = models.CharField(max_length=16, blank=True)
|
||||||
|
base_dirname = models.CharField(max_length=255, blank=True)
|
||||||
|
base_path = models.CharField(max_length=1024, blank=True)
|
||||||
|
base_snapshot_id = models.CharField(max_length=64, blank=True)
|
||||||
status = models.CharField(max_length=32, blank=True)
|
status = models.CharField(max_length=32, blank=True)
|
||||||
started_at = models.DateTimeField(null=True, blank=True)
|
started_at = models.DateTimeField(null=True, blank=True)
|
||||||
ended_at = models.DateTimeField(null=True, blank=True)
|
ended_at = models.DateTimeField(null=True, blank=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
discovered_at = models.DateTimeField(auto_now_add=True)
|
discovered_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
reviewed_by = models.CharField(max_length=150, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
@@ -120,10 +173,34 @@ class SnapshotRecord(models.Model):
|
|||||||
return f"{self.host}/{self.kind}/{self.dirname}"
|
return f"{self.host}/{self.kind}/{self.dirname}"
|
||||||
|
|
||||||
|
|
||||||
|
class PurgedSnapshot(models.Model):
|
||||||
|
class Action(models.TextChoices):
|
||||||
|
MANUAL = "manual", "Manual"
|
||||||
|
SCHEDULED = "scheduled", "Scheduled"
|
||||||
|
CLI = "cli", "CLI"
|
||||||
|
INCOMPLETE_CLEANUP = "incomplete_cleanup", "Incomplete cleanup"
|
||||||
|
|
||||||
|
host = models.ForeignKey(HostConfig, on_delete=models.SET_NULL, null=True, blank=True, related_name="purged_snapshots")
|
||||||
|
host_name = models.CharField(max_length=255)
|
||||||
|
kind = models.CharField(max_length=16)
|
||||||
|
dirname = models.CharField(max_length=255)
|
||||||
|
path = models.CharField(max_length=1024)
|
||||||
|
reason = models.CharField(max_length=512, blank=True)
|
||||||
|
action = models.CharField(max_length=32, choices=Action.choices)
|
||||||
|
triggered_by = models.CharField(max_length=150, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
purged_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-purged_at", "host_name", "dirname"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.host_name}/{self.kind}/{self.dirname}"
|
||||||
|
|
||||||
|
|
||||||
class ScheduleConfig(TimestampedModel):
|
class ScheduleConfig(TimestampedModel):
|
||||||
host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule")
|
host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule")
|
||||||
cron_expr = models.CharField(max_length=128)
|
cron_expr = models.CharField(max_length=128)
|
||||||
user = models.CharField(max_length=64, default="root")
|
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
prune = models.BooleanField(default=False)
|
prune = models.BooleanField(default=False)
|
||||||
prune_max_delete = models.PositiveIntegerField(default=10)
|
prune_max_delete = models.PositiveIntegerField(default=10)
|
||||||
|
|||||||
232
src/pobsync_backend/preflight.py
Normal file
232
src/pobsync_backend/preflight.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pobsync.config.merge import build_effective_config
|
||||||
|
from pobsync.rsync import build_ssh_command
|
||||||
|
|
||||||
|
from .config_repository import global_config_object_data, host_config_object_data
|
||||||
|
from .config_source import DjangoConfigSource
|
||||||
|
from .host_ops import collect_host_checks
|
||||||
|
from .models import GlobalConfig, HostConfig
|
||||||
|
from .self_check import SelfCheck
|
||||||
|
|
||||||
|
|
||||||
|
DRY_RUN_BLOCKING_CHECKS = {
|
||||||
|
"Host global config",
|
||||||
|
"Host address",
|
||||||
|
"Host SSH key file",
|
||||||
|
"Host effective source root",
|
||||||
|
"Host effective SSH user",
|
||||||
|
"Host effective SSH port",
|
||||||
|
"Host effective SSH credential",
|
||||||
|
"Host effective rsync recursion",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackupGate:
|
||||||
|
state: str
|
||||||
|
message: str
|
||||||
|
checks: list[SelfCheck]
|
||||||
|
real_blockers: list[SelfCheck]
|
||||||
|
dry_run_blockers: list[SelfCheck]
|
||||||
|
warnings: list[SelfCheck]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_queue_real(self) -> bool:
|
||||||
|
return not self.real_blockers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_queue_dry_run(self) -> bool:
|
||||||
|
return not self.dry_run_blockers
|
||||||
|
|
||||||
|
|
||||||
|
def collect_backup_gate(host: HostConfig, global_config: GlobalConfig | None = None) -> BackupGate:
|
||||||
|
checks = collect_host_checks(host, global_config)
|
||||||
|
remote_preflight_check = _remote_preflight_self_check(host)
|
||||||
|
if remote_preflight_check is not None:
|
||||||
|
checks.append(remote_preflight_check)
|
||||||
|
real_blockers = [check for check in checks if check.status == "failed"]
|
||||||
|
dry_run_blockers = [check for check in real_blockers if check.name in DRY_RUN_BLOCKING_CHECKS]
|
||||||
|
warnings = [check for check in checks if check.status == "warning"]
|
||||||
|
|
||||||
|
if real_blockers:
|
||||||
|
state = "blocked"
|
||||||
|
message = "Real backups are blocked until failed host checks are resolved."
|
||||||
|
elif warnings:
|
||||||
|
state = "warning"
|
||||||
|
message = "Backups can run, but review the warnings first."
|
||||||
|
else:
|
||||||
|
state = "ready"
|
||||||
|
message = "This host is ready for backup runs."
|
||||||
|
|
||||||
|
return BackupGate(
|
||||||
|
state=state,
|
||||||
|
message=message,
|
||||||
|
checks=checks,
|
||||||
|
real_blockers=real_blockers,
|
||||||
|
dry_run_blockers=dry_run_blockers,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict[str, Any]:
|
||||||
|
config = DjangoConfigSource().effective_config_for_host(host.host)
|
||||||
|
ssh_cfg = config.get("ssh", {}) or {}
|
||||||
|
rsync_cfg = config.get("rsync", {}) or {}
|
||||||
|
address = str(config.get("address") or host.address)
|
||||||
|
user = str(ssh_cfg.get("user") or "root")
|
||||||
|
source_root = str(config.get("source_root") or (config.get("defaults", {}) or {}).get("source_root") or "/")
|
||||||
|
rsync_binary = str(rsync_cfg.get("binary") or "rsync")
|
||||||
|
target = f"{user}@{address}"
|
||||||
|
ssh_cmd = build_ssh_command(ssh_cfg)
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
_run_remote_check(
|
||||||
|
name="SSH reachability",
|
||||||
|
command=[*ssh_cmd, "-oBatchMode=yes", target, "true"],
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
),
|
||||||
|
_run_remote_check(
|
||||||
|
name="Remote rsync",
|
||||||
|
command=[
|
||||||
|
*ssh_cmd,
|
||||||
|
"-oBatchMode=yes",
|
||||||
|
target,
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
f"command -v {shlex.quote(rsync_binary)} >/dev/null",
|
||||||
|
],
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
),
|
||||||
|
_run_remote_check(
|
||||||
|
name="Remote source root",
|
||||||
|
command=[
|
||||||
|
*ssh_cmd,
|
||||||
|
"-oBatchMode=yes",
|
||||||
|
target,
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
f"test -e {shlex.quote(source_root)} && test -r {shlex.quote(source_root)}",
|
||||||
|
],
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
result = {
|
||||||
|
"ok": all(check["ok"] for check in checks),
|
||||||
|
"checks": checks,
|
||||||
|
"target": target,
|
||||||
|
"source_root": source_root,
|
||||||
|
"rsync_binary": rsync_binary,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
}
|
||||||
|
host.config = {**(host.config or {}), "last_preflight": result}
|
||||||
|
host.save(update_fields=["config", "updated_at"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def effective_host_config_preview(host: HostConfig, global_config: GlobalConfig) -> dict[str, Any]:
|
||||||
|
config = build_effective_config(global_config_object_data(global_config), host_config_object_data(host))
|
||||||
|
credential = host.ssh_credential or global_config.default_ssh_credential
|
||||||
|
ssh = config.get("ssh", {}) or {}
|
||||||
|
rsync = config.get("rsync", {}) or {}
|
||||||
|
retention = config.get("retention", {}) or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source_root": config.get("source_root", ""),
|
||||||
|
"destination_subdir": (config.get("defaults", {}) or {}).get("destination_subdir", ""),
|
||||||
|
"includes": list(config.get("includes") or []),
|
||||||
|
"excludes": list(config.get("excludes_effective") or []),
|
||||||
|
"ssh": {
|
||||||
|
"user": ssh.get("user", ""),
|
||||||
|
"port": ssh.get("port", ""),
|
||||||
|
"options": list(ssh.get("options") or []),
|
||||||
|
"credential": str(credential) if credential else "",
|
||||||
|
},
|
||||||
|
"rsync": {
|
||||||
|
"binary": rsync.get("binary", ""),
|
||||||
|
"args": list(rsync.get("args_effective") or []),
|
||||||
|
"timeout_seconds": rsync.get("timeout_seconds", 0),
|
||||||
|
"bwlimit_kbps": rsync.get("bwlimit_kbps", 0),
|
||||||
|
},
|
||||||
|
"retention": {
|
||||||
|
"daily": retention.get("daily", 0),
|
||||||
|
"weekly": retention.get("weekly", 0),
|
||||||
|
"monthly": retention.get("monthly", 0),
|
||||||
|
"yearly": retention.get("yearly", 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_remote_check(*, name: str, command: list[str], timeout_seconds: int) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"ok": False,
|
||||||
|
"exit_code": 124,
|
||||||
|
"message": f"{name} timed out after {timeout_seconds}s.",
|
||||||
|
"detail": _clip_output((exc.stderr or exc.stdout or "").strip()),
|
||||||
|
}
|
||||||
|
except OSError as exc:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"ok": False,
|
||||||
|
"exit_code": None,
|
||||||
|
"message": f"{name} could not start.",
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"ok": result.returncode == 0,
|
||||||
|
"exit_code": result.returncode,
|
||||||
|
"message": f"{name} passed." if result.returncode == 0 else f"{name} failed.",
|
||||||
|
"detail": _clip_output((result.stderr or result.stdout or "").strip()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_preflight_self_check(host: HostConfig) -> SelfCheck | None:
|
||||||
|
preflight = (host.config or {}).get("last_preflight")
|
||||||
|
if not isinstance(preflight, dict):
|
||||||
|
return SelfCheck(
|
||||||
|
"Remote preflight",
|
||||||
|
"warning",
|
||||||
|
"No remote connection preflight has been run yet.",
|
||||||
|
"Run connection preflight before the first real backup.",
|
||||||
|
)
|
||||||
|
checks = preflight.get("checks")
|
||||||
|
if not isinstance(checks, list):
|
||||||
|
return SelfCheck("Remote preflight", "failed", "Stored remote preflight result is invalid.")
|
||||||
|
failed = [str(check.get("name", "unknown")) for check in checks if isinstance(check, dict) and not check.get("ok")]
|
||||||
|
if failed:
|
||||||
|
return SelfCheck(
|
||||||
|
"Remote preflight",
|
||||||
|
"failed",
|
||||||
|
"Remote connection preflight failed.",
|
||||||
|
", ".join(failed),
|
||||||
|
)
|
||||||
|
return SelfCheck(
|
||||||
|
"Remote preflight",
|
||||||
|
"ok",
|
||||||
|
"Remote connection preflight passed.",
|
||||||
|
f"{preflight.get('target', '')} {preflight.get('source_root', '')}".strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_output(value: str, *, max_chars: int = 800) -> str:
|
||||||
|
if len(value) <= max_chars:
|
||||||
|
return value
|
||||||
|
return f"{value[:max_chars]}..."
|
||||||
352
src/pobsync_backend/retention.py
Normal file
352
src/pobsync_backend/retention.py
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pobsync.errors import ConfigError
|
||||||
|
from pobsync.lock import acquire_host_lock
|
||||||
|
from pobsync.paths import PobsyncPaths
|
||||||
|
from pobsync.retention import Snapshot, apply_base_protection, build_retention_plan
|
||||||
|
from pobsync.util import sanitize_host
|
||||||
|
|
||||||
|
from .models import HostConfig, PurgedSnapshot, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
|
def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict[str, Any]:
|
||||||
|
host = sanitize_host(host)
|
||||||
|
if kind not in {"scheduled", "manual", "all"}:
|
||||||
|
raise ConfigError("kind must be scheduled, manual, or all")
|
||||||
|
|
||||||
|
host_config = _enabled_host_config(host)
|
||||||
|
retention = _retention_for_host(host_config)
|
||||||
|
snapshots = _snapshots_for_retention(host_config=host_config, kind=kind)
|
||||||
|
incomplete_snapshots = _incomplete_snapshots_for_host(host_config)
|
||||||
|
|
||||||
|
plan = build_retention_plan(
|
||||||
|
snapshots=snapshots,
|
||||||
|
retention=retention,
|
||||||
|
now=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
keep = set(plan.keep)
|
||||||
|
reasons = dict(plan.reasons)
|
||||||
|
if protect_bases:
|
||||||
|
keep, reasons = apply_base_protection(snapshots=snapshots, keep=keep, reasons=reasons)
|
||||||
|
|
||||||
|
delete = [snapshot for snapshot in snapshots if snapshot.dirname not in keep]
|
||||||
|
keep_items = [snapshot for snapshot in snapshots if snapshot.dirname in keep]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"host": host,
|
||||||
|
"kind": kind,
|
||||||
|
"protect_bases": bool(protect_bases),
|
||||||
|
"retention": retention,
|
||||||
|
"source": "sql",
|
||||||
|
"keep": sorted(keep),
|
||||||
|
"keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items],
|
||||||
|
"delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete],
|
||||||
|
"incomplete": [
|
||||||
|
_snapshot_to_item(snapshot, reasons=["incomplete snapshot; excluded from retention cleanup"])
|
||||||
|
for snapshot in incomplete_snapshots
|
||||||
|
],
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_sql_retention_apply(
|
||||||
|
*,
|
||||||
|
prefix: Path,
|
||||||
|
host: str,
|
||||||
|
kind: str,
|
||||||
|
protect_bases: bool,
|
||||||
|
yes: bool,
|
||||||
|
max_delete: int,
|
||||||
|
action: str = PurgedSnapshot.Action.MANUAL,
|
||||||
|
triggered_by: str = "",
|
||||||
|
acquire_lock: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
host = sanitize_host(host)
|
||||||
|
if not yes:
|
||||||
|
raise ConfigError("Refusing to delete snapshots without --yes")
|
||||||
|
if max_delete < 0:
|
||||||
|
raise ConfigError("--max-delete must be >= 0")
|
||||||
|
|
||||||
|
paths = PobsyncPaths(home=prefix)
|
||||||
|
|
||||||
|
def _do_apply() -> dict[str, Any]:
|
||||||
|
plan = run_sql_retention_plan(host=host, kind=kind, protect_bases=bool(protect_bases))
|
||||||
|
delete_list = plan.get("delete") or []
|
||||||
|
incomplete_list = plan.get("incomplete") or []
|
||||||
|
if not isinstance(delete_list, list):
|
||||||
|
raise ConfigError("Invalid retention plan output: delete is not a list")
|
||||||
|
if not isinstance(incomplete_list, list):
|
||||||
|
raise ConfigError("Invalid retention plan output: incomplete is not a list")
|
||||||
|
if max_delete == 0 and len(delete_list) > 0:
|
||||||
|
raise ConfigError("Deletion blocked by --max-delete=0")
|
||||||
|
if len(delete_list) > max_delete:
|
||||||
|
raise ConfigError(f"Refusing to delete {len(delete_list)} snapshots (exceeds --max-delete={max_delete})")
|
||||||
|
|
||||||
|
actions: list[str] = []
|
||||||
|
deleted: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for item in delete_list:
|
||||||
|
dirname = item.get("dirname") if isinstance(item, dict) else None
|
||||||
|
snap_kind = item.get("kind") if isinstance(item, dict) else None
|
||||||
|
snap_path = item.get("path") if isinstance(item, dict) else None
|
||||||
|
if not isinstance(dirname, str) or not isinstance(snap_kind, str) or not isinstance(snap_path, str):
|
||||||
|
continue
|
||||||
|
if snap_kind not in {"scheduled", "manual"}:
|
||||||
|
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
|
||||||
|
|
||||||
|
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
|
||||||
|
reason = str(item.get("reason") or "outside retention policy")
|
||||||
|
if not path.exists():
|
||||||
|
actions.append(f"skip missing {snap_kind}/{dirname}")
|
||||||
|
continue
|
||||||
|
if not path.is_dir():
|
||||||
|
raise ConfigError(f"Refusing to delete non-directory path: {path}")
|
||||||
|
|
||||||
|
_remove_snapshot_tree(path)
|
||||||
|
_record_purged_snapshot(
|
||||||
|
host_config=_enabled_host_config(host),
|
||||||
|
kind=snap_kind,
|
||||||
|
dirname=dirname,
|
||||||
|
path=path,
|
||||||
|
reason=reason,
|
||||||
|
action=action,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
metadata={"source": "retention", "protect_bases": bool(protect_bases), "retention_kind": kind},
|
||||||
|
)
|
||||||
|
SnapshotRecord.objects.filter(host__host=host, kind=snap_kind, dirname=dirname).delete()
|
||||||
|
actions.append(f"deleted {snap_kind} {dirname}")
|
||||||
|
deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path), "reason": reason})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"host": host,
|
||||||
|
"kind": kind,
|
||||||
|
"protect_bases": bool(protect_bases),
|
||||||
|
"max_delete": max_delete,
|
||||||
|
"source": "sql",
|
||||||
|
"planned_delete_count": len(delete_list),
|
||||||
|
"incomplete_ignored_count": len(incomplete_list),
|
||||||
|
"deleted": deleted,
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
if acquire_lock:
|
||||||
|
with acquire_host_lock(paths.locks_dir, host, command="retention-apply"):
|
||||||
|
return _do_apply()
|
||||||
|
return _do_apply()
|
||||||
|
|
||||||
|
|
||||||
|
def run_incomplete_cleanup(
|
||||||
|
*,
|
||||||
|
prefix: Path,
|
||||||
|
host: str,
|
||||||
|
yes: bool,
|
||||||
|
max_delete: int,
|
||||||
|
triggered_by: str = "",
|
||||||
|
acquire_lock: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
host = sanitize_host(host)
|
||||||
|
if not yes:
|
||||||
|
raise ConfigError("Refusing to delete incomplete snapshots without --yes")
|
||||||
|
if max_delete < 0:
|
||||||
|
raise ConfigError("--max-delete must be >= 0")
|
||||||
|
|
||||||
|
paths = PobsyncPaths(home=prefix)
|
||||||
|
|
||||||
|
def _do_cleanup() -> dict[str, Any]:
|
||||||
|
host_config = _enabled_host_config(host)
|
||||||
|
incomplete_list = [
|
||||||
|
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
|
||||||
|
for snapshot in _incomplete_snapshots_for_host(host_config)
|
||||||
|
]
|
||||||
|
if max_delete == 0 and len(incomplete_list) > 0:
|
||||||
|
raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
|
||||||
|
if len(incomplete_list) > max_delete:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Refusing to delete {len(incomplete_list)} incomplete snapshots (exceeds --max-delete={max_delete})"
|
||||||
|
)
|
||||||
|
|
||||||
|
actions: list[str] = []
|
||||||
|
deleted: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for item in incomplete_list:
|
||||||
|
dirname = item["dirname"]
|
||||||
|
snap_path = Path(item["path"])
|
||||||
|
path = _snapshot_delete_path(path=snap_path, dirname=dirname)
|
||||||
|
_validate_incomplete_delete_path(host=host, path=path, dirname=dirname)
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
actions.append(f"skip missing incomplete/{dirname}")
|
||||||
|
elif not path.is_dir():
|
||||||
|
raise ConfigError(f"Refusing to delete non-directory path: {path}")
|
||||||
|
else:
|
||||||
|
_remove_snapshot_tree(path)
|
||||||
|
actions.append(f"deleted incomplete {dirname}")
|
||||||
|
|
||||||
|
_record_purged_snapshot(
|
||||||
|
host_config=host_config,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname=dirname,
|
||||||
|
path=path,
|
||||||
|
reason="manual incomplete cleanup",
|
||||||
|
action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
metadata={"source": "incomplete_cleanup"},
|
||||||
|
)
|
||||||
|
SnapshotRecord.objects.filter(
|
||||||
|
host__host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname=dirname,
|
||||||
|
).delete()
|
||||||
|
deleted.append({"dirname": dirname, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(path)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"host": host,
|
||||||
|
"kind": SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
"max_delete": max_delete,
|
||||||
|
"source": "sql",
|
||||||
|
"planned_delete_count": len(incomplete_list),
|
||||||
|
"deleted": deleted,
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
if acquire_lock:
|
||||||
|
with acquire_host_lock(paths.locks_dir, host, command="incomplete-cleanup"):
|
||||||
|
return _do_cleanup()
|
||||||
|
return _do_cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_host_config(host: str) -> HostConfig:
|
||||||
|
try:
|
||||||
|
return HostConfig.objects.get(host=host, enabled=True)
|
||||||
|
except HostConfig.DoesNotExist as exc:
|
||||||
|
raise ConfigError(f"Missing enabled host {host!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _retention_for_host(host_config: HostConfig) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"daily": host_config.retention_daily,
|
||||||
|
"weekly": host_config.retention_weekly,
|
||||||
|
"monthly": host_config.retention_monthly,
|
||||||
|
"yearly": host_config.retention_yearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshots_for_retention(*, host_config: HostConfig, kind: str) -> list[Snapshot]:
|
||||||
|
kinds = ["scheduled", "manual"] if kind == "all" else [kind]
|
||||||
|
records = (
|
||||||
|
SnapshotRecord.objects.filter(host=host_config, kind__in=kinds)
|
||||||
|
.exclude(kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
|
.select_related("base")
|
||||||
|
.order_by("-started_at", "dirname")
|
||||||
|
)
|
||||||
|
return [_snapshot_from_record(record) for record in records]
|
||||||
|
|
||||||
|
|
||||||
|
def _incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
|
||||||
|
records = (
|
||||||
|
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
|
.select_related("base")
|
||||||
|
.order_by("-started_at", "dirname")
|
||||||
|
)
|
||||||
|
return [_snapshot_from_record(record) for record in records]
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_from_record(record: SnapshotRecord) -> Snapshot:
|
||||||
|
return Snapshot(
|
||||||
|
kind=record.kind,
|
||||||
|
dirname=record.dirname,
|
||||||
|
path=record.path,
|
||||||
|
dt=record.started_at or datetime.fromtimestamp(0, tz=timezone.utc),
|
||||||
|
status=record.status or None,
|
||||||
|
base=_base_meta_from_record(record),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_meta_from_record(record: SnapshotRecord) -> dict[str, str] | None:
|
||||||
|
if record.base is not None:
|
||||||
|
return {
|
||||||
|
"kind": record.base.kind,
|
||||||
|
"dirname": record.base.dirname,
|
||||||
|
"path": record.base.path,
|
||||||
|
}
|
||||||
|
if record.base_kind and record.base_dirname:
|
||||||
|
return {
|
||||||
|
"kind": record.base_kind,
|
||||||
|
"dirname": record.base_dirname,
|
||||||
|
"path": record.base_path,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_to_item(snapshot: Snapshot, *, reasons: list[str]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"dirname": snapshot.dirname,
|
||||||
|
"kind": snapshot.kind,
|
||||||
|
"path": snapshot.path,
|
||||||
|
"dt": snapshot.dt.isoformat(),
|
||||||
|
"status": snapshot.status,
|
||||||
|
"reasons": reasons,
|
||||||
|
"reason": ", ".join(reasons),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
||||||
|
if path.name == "data" and path.parent.name == dirname:
|
||||||
|
return path.parent
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _record_purged_snapshot(
|
||||||
|
*,
|
||||||
|
host_config: HostConfig,
|
||||||
|
kind: str,
|
||||||
|
dirname: str,
|
||||||
|
path: Path,
|
||||||
|
reason: str,
|
||||||
|
action: str,
|
||||||
|
triggered_by: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
PurgedSnapshot.objects.create(
|
||||||
|
host=host_config,
|
||||||
|
host_name=host_config.host,
|
||||||
|
kind=kind,
|
||||||
|
dirname=dirname,
|
||||||
|
path=str(path),
|
||||||
|
reason=reason,
|
||||||
|
action=action,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_incomplete_delete_path(*, host: str, path: Path, dirname: str) -> None:
|
||||||
|
path_parts = path.parts
|
||||||
|
if path.name != dirname or ".incomplete" not in path_parts or host not in path_parts:
|
||||||
|
raise ConfigError(f"Refusing to delete unexpected incomplete snapshot path: {path}")
|
||||||
|
incomplete_index = path_parts.index(".incomplete")
|
||||||
|
if incomplete_index == 0 or path_parts[incomplete_index - 1] != host:
|
||||||
|
raise ConfigError(f"Refusing to delete incomplete snapshot outside host backup root: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_snapshot_tree(path: Path) -> None:
|
||||||
|
_make_directories_user_writable(path)
|
||||||
|
shutil.rmtree(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_directories_user_writable(path: Path) -> None:
|
||||||
|
for directory in [path, *[child for child in path.rglob("*") if child.is_dir() and not child.is_symlink()]]:
|
||||||
|
mode = directory.stat().st_mode
|
||||||
|
if mode & stat.S_IWUSR:
|
||||||
|
continue
|
||||||
|
directory.chmod(mode | stat.S_IWUSR)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -36,6 +36,17 @@ def is_due(expr: str, moment: datetime) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def next_due_after(expr: str, moment: datetime, *, max_days: int = 366) -> datetime | None:
|
||||||
|
parse_cron_expr(expr)
|
||||||
|
candidate = moment.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
||||||
|
deadline = candidate + timedelta(days=max_days)
|
||||||
|
while candidate <= deadline:
|
||||||
|
if is_due(expr, candidate):
|
||||||
|
return candidate
|
||||||
|
candidate += timedelta(minutes=1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _field_matches(field: str, value: int, min_value: int, max_value: int, sunday_alias: bool = False) -> bool:
|
def _field_matches(field: str, value: int, min_value: int, max_value: int, sunday_alias: bool = False) -> bool:
|
||||||
for part in field.split(","):
|
for part in field.split(","):
|
||||||
if _part_matches(part.strip(), value, min_value, max_value, sunday_alias=sunday_alias):
|
if _part_matches(part.strip(), value, min_value, max_value, sunday_alias=sunday_alias):
|
||||||
|
|||||||
329
src/pobsync_backend/self_check.py
Normal file
329
src/pobsync_backend/self_check.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
from .models import GlobalConfig
|
||||||
|
|
||||||
|
|
||||||
|
CheckStatus = Literal["ok", "warning", "failed", "skipped"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SelfCheck:
|
||||||
|
name: str
|
||||||
|
status: CheckStatus
|
||||||
|
message: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_self_checks() -> list[SelfCheck]:
|
||||||
|
checks: list[SelfCheck] = []
|
||||||
|
checks.extend(_django_checks())
|
||||||
|
checks.extend(_install_checks())
|
||||||
|
checks.extend(_path_checks())
|
||||||
|
checks.extend(_binary_checks())
|
||||||
|
checks.extend(_database_checks())
|
||||||
|
checks.extend(_config_checks())
|
||||||
|
checks.extend(_systemd_checks())
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _native_runtime_available() -> bool:
|
||||||
|
return Path("/run/systemd/system").exists() and shutil.which("systemctl") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"ok": sum(1 for check in checks if check.status == "ok"),
|
||||||
|
"warning": sum(1 for check in checks if check.status == "warning"),
|
||||||
|
"failed": sum(1 for check in checks if check.status == "failed"),
|
||||||
|
"skipped": sum(1 for check in checks if check.status == "skipped"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _django_checks() -> list[SelfCheck]:
|
||||||
|
checks = [
|
||||||
|
SelfCheck(
|
||||||
|
"Django debug",
|
||||||
|
"warning" if settings.DEBUG else "ok",
|
||||||
|
"DEBUG is enabled." if settings.DEBUG else "DEBUG is disabled.",
|
||||||
|
),
|
||||||
|
SelfCheck(
|
||||||
|
"Django secret key",
|
||||||
|
"failed" if settings.SECRET_KEY == "dev-only-change-me" else "ok",
|
||||||
|
"Default development secret key is still active."
|
||||||
|
if settings.SECRET_KEY == "dev-only-change-me"
|
||||||
|
else "Secret key is configured.",
|
||||||
|
),
|
||||||
|
SelfCheck(
|
||||||
|
"Allowed hosts",
|
||||||
|
"ok" if settings.ALLOWED_HOSTS else "failed",
|
||||||
|
", ".join(settings.ALLOWED_HOSTS) if settings.ALLOWED_HOSTS else "No allowed hosts configured.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _path_checks() -> list[SelfCheck]:
|
||||||
|
checks = []
|
||||||
|
checks.append(
|
||||||
|
_path_check(
|
||||||
|
"State root",
|
||||||
|
Path(settings.POBSYNC_HOME),
|
||||||
|
must_be_absolute=True,
|
||||||
|
must_be_writable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
_path_check(
|
||||||
|
"Backup root",
|
||||||
|
Path(settings.POBSYNC_BACKUP_ROOT),
|
||||||
|
must_be_absolute=True,
|
||||||
|
must_exist=True,
|
||||||
|
must_be_writable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
_path_check(
|
||||||
|
"Static root",
|
||||||
|
Path(settings.STATIC_ROOT),
|
||||||
|
must_be_absolute=True,
|
||||||
|
must_exist=False,
|
||||||
|
must_be_writable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_settings = settings.DATABASES["default"]
|
||||||
|
if db_settings["ENGINE"] == "django.db.backends.sqlite3":
|
||||||
|
sqlite_path = Path(str(db_settings["NAME"]))
|
||||||
|
checks.append(
|
||||||
|
_path_check(
|
||||||
|
"SQLite directory",
|
||||||
|
sqlite_path.parent,
|
||||||
|
must_be_absolute=True,
|
||||||
|
must_exist=True,
|
||||||
|
must_be_writable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(_sqlite_database_check(sqlite_path))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _install_checks() -> list[SelfCheck]:
|
||||||
|
if not _native_runtime_available() and not Path(settings.POBSYNC_ENV_FILE).exists():
|
||||||
|
return [
|
||||||
|
SelfCheck(
|
||||||
|
"Environment file",
|
||||||
|
"skipped",
|
||||||
|
"Native environment file is not configured in this runtime.",
|
||||||
|
"This is expected inside Docker or local development.",
|
||||||
|
),
|
||||||
|
SelfCheck(
|
||||||
|
"Service user",
|
||||||
|
"skipped",
|
||||||
|
"Native service user check is not available in this runtime.",
|
||||||
|
"This is expected inside Docker or local development.",
|
||||||
|
),
|
||||||
|
SelfCheck(
|
||||||
|
"Backup root owner",
|
||||||
|
"skipped",
|
||||||
|
"Native backup root ownership check is not available in this runtime.",
|
||||||
|
"This is expected inside Docker or local development.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
checks = [_env_file_check(Path(settings.POBSYNC_ENV_FILE)), _service_user_check()]
|
||||||
|
checks.append(_backup_root_owner_check(Path(settings.POBSYNC_BACKUP_ROOT)))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _env_file_check(path: Path) -> SelfCheck:
|
||||||
|
if not path.is_absolute():
|
||||||
|
return SelfCheck("Environment file", "failed", f"{path} is not absolute.")
|
||||||
|
if not path.exists():
|
||||||
|
return SelfCheck("Environment file", "failed", f"{path} does not exist.")
|
||||||
|
if not path.is_file():
|
||||||
|
return SelfCheck("Environment file", "failed", f"{path} is not a regular file.")
|
||||||
|
if not os.access(path, os.R_OK):
|
||||||
|
return SelfCheck("Environment file", "failed", f"{path} is not readable by this process.")
|
||||||
|
return SelfCheck("Environment file", "ok", str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _service_user_check() -> SelfCheck:
|
||||||
|
expected_user = settings.POBSYNC_SERVICE_USER
|
||||||
|
try:
|
||||||
|
current_user = pwd.getpwuid(os.geteuid()).pw_name
|
||||||
|
except KeyError:
|
||||||
|
return SelfCheck("Service user", "failed", f"Current uid {os.geteuid()} has no passwd entry.")
|
||||||
|
if current_user != expected_user:
|
||||||
|
return SelfCheck(
|
||||||
|
"Service user",
|
||||||
|
"warning",
|
||||||
|
f"Current process runs as {current_user}, expected {expected_user}.",
|
||||||
|
"Run terminal checks with sudo -u <service-user> pobsync-manage check_pobsync_install.",
|
||||||
|
)
|
||||||
|
return SelfCheck("Service user", "ok", current_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_root_owner_check(path: Path) -> SelfCheck:
|
||||||
|
if not path.exists():
|
||||||
|
return SelfCheck("Backup root owner", "failed", f"{path} does not exist.")
|
||||||
|
expected_user = settings.POBSYNC_SERVICE_USER
|
||||||
|
try:
|
||||||
|
owner = pwd.getpwuid(path.stat().st_uid).pw_name
|
||||||
|
except KeyError:
|
||||||
|
return SelfCheck("Backup root owner", "warning", f"{path} owner uid {path.stat().st_uid} has no passwd entry.")
|
||||||
|
if owner != expected_user:
|
||||||
|
return SelfCheck(
|
||||||
|
"Backup root owner",
|
||||||
|
"warning",
|
||||||
|
f"{path} is owned by {owner}, expected {expected_user}.",
|
||||||
|
)
|
||||||
|
return SelfCheck("Backup root owner", "ok", f"{path} owner={owner}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_database_check(path: Path) -> SelfCheck:
|
||||||
|
if not path.is_absolute():
|
||||||
|
return SelfCheck("SQLite database", "failed", f"{path} is not absolute.")
|
||||||
|
if not path.exists():
|
||||||
|
return SelfCheck("SQLite database", "warning", f"{path} does not exist yet.")
|
||||||
|
if not path.is_file():
|
||||||
|
return SelfCheck("SQLite database", "failed", f"{path} is not a regular file.")
|
||||||
|
if not os.access(path, os.R_OK | os.W_OK):
|
||||||
|
return SelfCheck("SQLite database", "failed", f"{path} is not readable and writable by this process.")
|
||||||
|
return SelfCheck("SQLite database", "ok", str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _path_check(
|
||||||
|
name: str,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
must_be_absolute: bool,
|
||||||
|
must_exist: bool = False,
|
||||||
|
must_be_writable: bool,
|
||||||
|
) -> SelfCheck:
|
||||||
|
if must_be_absolute and not path.is_absolute():
|
||||||
|
return SelfCheck(name, "failed", f"{path} is not absolute.")
|
||||||
|
if must_exist and not path.exists():
|
||||||
|
return SelfCheck(name, "failed", f"{path} does not exist.")
|
||||||
|
|
||||||
|
target = path if path.exists() else path.parent
|
||||||
|
if not target.exists():
|
||||||
|
return SelfCheck(name, "failed", f"{target} does not exist.")
|
||||||
|
if must_be_writable and not os.access(target, os.W_OK):
|
||||||
|
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
|
||||||
|
return SelfCheck(name, "ok", str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_checks() -> list[SelfCheck]:
|
||||||
|
checks = []
|
||||||
|
for binary in ("rsync", "ssh", "ssh-keygen"):
|
||||||
|
path = shutil.which(binary)
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
f"Binary: {binary}",
|
||||||
|
"ok" if path else "failed",
|
||||||
|
path or f"{binary} was not found in PATH.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gunicorn_path = shutil.which("gunicorn") or Path(sys.executable).parent / "gunicorn"
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
"Binary: gunicorn",
|
||||||
|
"ok" if Path(gunicorn_path).exists() else "failed",
|
||||||
|
str(gunicorn_path) if Path(gunicorn_path).exists() else "gunicorn was not found in PATH or next to Python.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _database_checks() -> list[SelfCheck]:
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
cursor.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
return [SelfCheck("Database connection", "failed", f"{type(exc).__name__}: {exc}")]
|
||||||
|
return [SelfCheck("Database connection", "ok", settings.DATABASES["default"]["ENGINE"])]
|
||||||
|
|
||||||
|
|
||||||
|
def _config_checks() -> list[SelfCheck]:
|
||||||
|
try:
|
||||||
|
global_config = GlobalConfig.objects.get(name="default")
|
||||||
|
except GlobalConfig.DoesNotExist:
|
||||||
|
return [SelfCheck("Global config", "warning", "Default global config has not been created yet.")]
|
||||||
|
|
||||||
|
status: CheckStatus = "ok"
|
||||||
|
message = "Default global config exists."
|
||||||
|
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
|
||||||
|
status = "warning"
|
||||||
|
message = "Saved backup root differs from the active backup root."
|
||||||
|
return [
|
||||||
|
SelfCheck(
|
||||||
|
"Global config",
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
f"saved={global_config.backup_root} active={settings.POBSYNC_BACKUP_ROOT}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_checks() -> list[SelfCheck]:
|
||||||
|
if not _native_runtime_available():
|
||||||
|
return [
|
||||||
|
SelfCheck(
|
||||||
|
"Systemd services",
|
||||||
|
"skipped",
|
||||||
|
"systemd is not available in this runtime.",
|
||||||
|
"This is expected inside Docker.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
checks = []
|
||||||
|
for service in ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service"):
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "is-active", service],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
active_state = result.stdout.strip() or result.stderr.strip()
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
service,
|
||||||
|
"ok" if result.returncode == 0 else "failed",
|
||||||
|
active_state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if shutil.which("journalctl") is not None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["journalctl", "--no-pager", "-n", "1", "-u", "pobsync-web.service"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
journal_error = result.stderr.strip()
|
||||||
|
journal_denied = "No journal files were opened" in journal_error or "permission" in journal_error.lower()
|
||||||
|
has_journal_access = result.returncode == 0 and not journal_denied
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
"Journal access",
|
||||||
|
"ok" if has_journal_access else "failed",
|
||||||
|
"pobsync can read service logs." if has_journal_access else "pobsync cannot read service logs.",
|
||||||
|
journal_error,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return checks
|
||||||
@@ -55,6 +55,7 @@ def discover_snapshots(
|
|||||||
created += 1
|
created += 1
|
||||||
else:
|
else:
|
||||||
updated += 1
|
updated += 1
|
||||||
|
resolve_base_links(host=host_config)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -64,10 +65,65 @@ def discover_snapshots(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_snapshot_discovery(
|
||||||
|
*,
|
||||||
|
host: HostConfig,
|
||||||
|
global_config: GlobalConfig | None = None,
|
||||||
|
kinds: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
global_config = global_config or GlobalConfig.objects.get(name="default")
|
||||||
|
except GlobalConfig.DoesNotExist:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "missing_global_config",
|
||||||
|
"message": "Create the default global config before discovering snapshots.",
|
||||||
|
"backup_root": "",
|
||||||
|
"host_root": "",
|
||||||
|
"host_root_exists": False,
|
||||||
|
"kind_counts": {},
|
||||||
|
"total_candidates": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
kinds = kinds or ["scheduled", "manual", "incomplete"]
|
||||||
|
host_root = resolve_host_root(global_config.backup_root, host.host)
|
||||||
|
kind_counts = {kind: len(list(iter_snapshot_dirs(host_root, kind))) for kind in kinds}
|
||||||
|
total_candidates = sum(kind_counts.values())
|
||||||
|
host_root_exists = host_root.exists()
|
||||||
|
|
||||||
|
if not host_root_exists:
|
||||||
|
reason = "missing_host_root"
|
||||||
|
message = f"Host backup directory does not exist yet: {host_root}"
|
||||||
|
elif total_candidates == 0:
|
||||||
|
reason = "no_snapshots"
|
||||||
|
message = f"No snapshot directories found below {host_root}."
|
||||||
|
else:
|
||||||
|
reason = "ready"
|
||||||
|
message = f"Found {total_candidates} snapshot directories below {host_root}."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"reason": reason,
|
||||||
|
"message": message,
|
||||||
|
"backup_root": str(global_config.backup_root),
|
||||||
|
"host_root": str(host_root),
|
||||||
|
"host_root_exists": host_root_exists,
|
||||||
|
"kind_counts": kind_counts,
|
||||||
|
"total_candidates": total_candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def upsert_snapshot_record(*, host: HostConfig, kind: str, snapshot_dir: Path) -> tuple[SnapshotRecord, bool]:
|
def upsert_snapshot_record(*, host: HostConfig, kind: str, snapshot_dir: Path) -> tuple[SnapshotRecord, bool]:
|
||||||
meta = read_snapshot_meta(snapshot_dir)
|
meta = read_snapshot_meta(snapshot_dir)
|
||||||
|
base_defaults = _base_defaults_from_meta(meta)
|
||||||
defaults = {
|
defaults = {
|
||||||
"path": str(snapshot_dir),
|
"path": str(snapshot_dir),
|
||||||
|
**base_defaults,
|
||||||
|
"base": _resolve_base_record(
|
||||||
|
host=host,
|
||||||
|
kind=base_defaults["base_kind"],
|
||||||
|
dirname=base_defaults["base_dirname"],
|
||||||
|
),
|
||||||
"status": str(meta.get("status") or ""),
|
"status": str(meta.get("status") or ""),
|
||||||
"started_at": parse_snapshot_datetime(snapshot_dir.name, meta, "started_at"),
|
"started_at": parse_snapshot_datetime(snapshot_dir.name, meta, "started_at"),
|
||||||
"ended_at": parse_snapshot_datetime(snapshot_dir.name, meta, "ended_at"),
|
"ended_at": parse_snapshot_datetime(snapshot_dir.name, meta, "ended_at"),
|
||||||
@@ -81,6 +137,26 @@ def upsert_snapshot_record(*, host: HostConfig, kind: str, snapshot_dir: Path) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_base_links(*, host: HostConfig | None = None) -> int:
|
||||||
|
snapshot_qs = SnapshotRecord.objects.exclude(base_dirname="").filter(base__isnull=True)
|
||||||
|
if host is not None:
|
||||||
|
snapshot_qs = snapshot_qs.filter(host=host)
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for snapshot in snapshot_qs.select_related("host"):
|
||||||
|
base = _resolve_base_record(
|
||||||
|
host=snapshot.host,
|
||||||
|
kind=snapshot.base_kind,
|
||||||
|
dirname=snapshot.base_dirname,
|
||||||
|
)
|
||||||
|
if base is None:
|
||||||
|
continue
|
||||||
|
snapshot.base = base
|
||||||
|
snapshot.save(update_fields=["base"])
|
||||||
|
updated += 1
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def infer_snapshot_kind(snapshot_path: Path) -> str:
|
def infer_snapshot_kind(snapshot_path: Path) -> str:
|
||||||
parent = snapshot_path.parent.name
|
parent = snapshot_path.parent.name
|
||||||
if parent == "scheduled":
|
if parent == "scheduled":
|
||||||
@@ -92,6 +168,29 @@ def infer_snapshot_kind(snapshot_path: Path) -> str:
|
|||||||
raise ValueError(f"Cannot infer snapshot kind from path: {snapshot_path}")
|
raise ValueError(f"Cannot infer snapshot kind from path: {snapshot_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _base_defaults_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
base = meta.get("base")
|
||||||
|
if not isinstance(base, dict):
|
||||||
|
base = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_kind": _base_value(base.get("kind")),
|
||||||
|
"base_dirname": _base_value(base.get("dirname")),
|
||||||
|
"base_path": _base_value(base.get("path")),
|
||||||
|
"base_snapshot_id": _base_value(base.get("id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_value(value: Any) -> str:
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_base_record(*, host: HostConfig, kind: str, dirname: str) -> SnapshotRecord | None:
|
||||||
|
if not kind or not dirname:
|
||||||
|
return None
|
||||||
|
return SnapshotRecord.objects.filter(host=host, kind=kind, dirname=dirname).first()
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_z(value: str) -> datetime | None:
|
def _parse_iso_z(value: str) -> datetime | None:
|
||||||
try:
|
try:
|
||||||
if value.endswith("Z"):
|
if value.endswith("Z"):
|
||||||
|
|||||||
145
src/pobsync_backend/ssh_keys.py
Normal file
145
src/pobsync_backend/ssh_keys.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .models import SshCredential
|
||||||
|
|
||||||
|
|
||||||
|
class SshKeyError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def credential_dir(credential: SshCredential) -> Path:
|
||||||
|
return Path(settings.POBSYNC_HOME) / "state" / "ssh-credentials" / str(credential.pk)
|
||||||
|
|
||||||
|
|
||||||
|
def identity_path(credential: SshCredential) -> Path:
|
||||||
|
if credential.key_path:
|
||||||
|
return Path(credential.key_path)
|
||||||
|
return credential_dir(credential) / "identity"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ssh_key(credential: SshCredential, *, key_type: str = "ed25519", force: bool = False) -> SshCredential:
|
||||||
|
if credential.pk is None:
|
||||||
|
raise SshKeyError("Credential must be saved before generating an SSH key.")
|
||||||
|
if shutil.which("ssh-keygen") is None:
|
||||||
|
raise SshKeyError("ssh-keygen is not available.")
|
||||||
|
|
||||||
|
key_dir = credential_dir(credential)
|
||||||
|
key_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
os.chmod(key_dir, 0o700)
|
||||||
|
|
||||||
|
private_key = key_dir / "identity"
|
||||||
|
public_key_file = key_dir / "identity.pub"
|
||||||
|
if force:
|
||||||
|
private_key.unlink(missing_ok=True)
|
||||||
|
public_key_file.unlink(missing_ok=True)
|
||||||
|
elif private_key.exists() or public_key_file.exists():
|
||||||
|
raise SshKeyError(f"SSH key already exists for {credential.name}.")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"ssh-keygen",
|
||||||
|
"-t",
|
||||||
|
key_type,
|
||||||
|
"-N",
|
||||||
|
"",
|
||||||
|
"-C",
|
||||||
|
f"pobsync:{credential.name}",
|
||||||
|
"-f",
|
||||||
|
str(private_key),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SshKeyError(result.stderr.strip() or "ssh-keygen failed.")
|
||||||
|
|
||||||
|
os.chmod(private_key, 0o600)
|
||||||
|
public_key = public_key_file.read_text(encoding="utf-8").strip()
|
||||||
|
fingerprint = fingerprint_for_key(private_key)
|
||||||
|
|
||||||
|
credential.private_key = ""
|
||||||
|
credential.public_key = public_key
|
||||||
|
credential.key_path = str(private_key)
|
||||||
|
credential.key_type = key_type
|
||||||
|
credential.fingerprint = fingerprint
|
||||||
|
credential.generated = True
|
||||||
|
credential.save(update_fields=["private_key", "public_key", "key_path", "key_type", "fingerprint", "generated", "updated_at"])
|
||||||
|
return credential
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint_for_key(private_key: Path) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ssh-keygen", "-lf", str(private_key)],
|
||||||
|
check=False,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SshKeyError(result.stderr.strip() or "Could not fingerprint SSH key.")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_generated_key_files(credential: SshCredential) -> None:
|
||||||
|
path = identity_path(credential)
|
||||||
|
allowed_root = (Path(settings.POBSYNC_HOME) / "state" / "ssh-credentials").resolve()
|
||||||
|
try:
|
||||||
|
resolved = path.resolve()
|
||||||
|
except FileNotFoundError:
|
||||||
|
resolved = path
|
||||||
|
|
||||||
|
if allowed_root not in resolved.parents:
|
||||||
|
raise SshKeyError(f"Refusing to delete key outside {allowed_root}.")
|
||||||
|
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
path.with_suffix(path.suffix + ".pub").unlink(missing_ok=True)
|
||||||
|
if path.name == "identity":
|
||||||
|
(path.parent / "identity.pub").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_known_host(address: str, *, port: int = 22, timeout: int = 5) -> str:
|
||||||
|
if shutil.which("ssh-keyscan") is None:
|
||||||
|
raise SshKeyError("ssh-keyscan is not available.")
|
||||||
|
|
||||||
|
command = ["ssh-keyscan", "-T", str(timeout), "-p", str(port), address]
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=False,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 2,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 and not result.stdout.strip():
|
||||||
|
raise SshKeyError(result.stderr.strip() or f"Could not scan SSH host key for {address}.")
|
||||||
|
|
||||||
|
lines = [line.strip() for line in result.stdout.splitlines() if line.strip() and not line.startswith("#")]
|
||||||
|
if not lines:
|
||||||
|
raise SshKeyError(f"ssh-keyscan returned no host keys for {address}.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_known_hosts(existing: str, scanned: str) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for line in [*existing.splitlines(), *scanned.splitlines()]:
|
||||||
|
normalized = line.strip()
|
||||||
|
if not normalized or normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
lines.append(normalized)
|
||||||
|
return "\n".join(lines) + ("\n" if lines else "")
|
||||||
218
src/pobsync_backend/stats_summary.py
Normal file
218
src/pobsync_backend/stats_summary.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from pobsync.run_stats import filesystem_capacity
|
||||||
|
|
||||||
|
from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
|
def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: GlobalConfig | None) -> dict[str, Any]:
|
||||||
|
runs = list(
|
||||||
|
BackupRun.objects.select_related("host", "snapshot")
|
||||||
|
.filter(status__in=_COMPLETED_BACKUP_STATUSES)
|
||||||
|
.order_by("-started_at", "-created_at")[:100]
|
||||||
|
)
|
||||||
|
real_runs = [_run_summary(run) for run in runs if _is_real_run(run)]
|
||||||
|
real_runs = [run for run in real_runs if run["has_stats"]]
|
||||||
|
|
||||||
|
for host in hosts:
|
||||||
|
host.stats_summary = collect_host_stats(host=host)
|
||||||
|
|
||||||
|
literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in real_runs]
|
||||||
|
literal_values = [value for value in literal_values if value is not None]
|
||||||
|
matched_values = [_int_at(run, "rsync", "matched_data_bytes") for run in real_runs]
|
||||||
|
matched_values = [value for value in matched_values if value is not None]
|
||||||
|
duration_values = [_int_at(run, "duration_seconds") for run in real_runs]
|
||||||
|
duration_values = [value for value in duration_values if value is not None]
|
||||||
|
|
||||||
|
avg_literal = _average(literal_values)
|
||||||
|
total_literal = sum(literal_values)
|
||||||
|
total_matched = sum(matched_values)
|
||||||
|
savings_basis = total_literal + total_matched
|
||||||
|
capacity = _capacity_from_system(global_config) or _latest_capacity_from_runs(real_runs) or {}
|
||||||
|
available = _int_at(capacity, "available_bytes")
|
||||||
|
daily_literal = _average_daily_literal(real_runs)
|
||||||
|
|
||||||
|
link_dest_savings_ratio = round(total_matched / savings_basis, 4) if savings_basis else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"runs_sampled": len(real_runs),
|
||||||
|
"avg_duration_seconds": _average(duration_values),
|
||||||
|
"avg_daily_literal_data_bytes": daily_literal,
|
||||||
|
"avg_literal_data_bytes": avg_literal,
|
||||||
|
"total_literal_data_bytes": total_literal,
|
||||||
|
"total_matched_data_bytes": total_matched,
|
||||||
|
"link_dest_savings_ratio": link_dest_savings_ratio,
|
||||||
|
"link_dest_savings_percent": round(link_dest_savings_ratio * 100, 1) if link_dest_savings_ratio is not None else None,
|
||||||
|
"estimated_runs_until_full": int(available / avg_literal) if available and avg_literal else None,
|
||||||
|
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
|
||||||
|
"capacity": capacity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
|
||||||
|
runs = list(host.runs.select_related("snapshot").order_by("-started_at", "-created_at")[:50])
|
||||||
|
real_runs = [_run_summary(run) for run in runs if _is_real_run(run)]
|
||||||
|
completed_real_runs = [run for run in real_runs if run["status"] in _COMPLETED_BACKUP_STATUSES]
|
||||||
|
trend_runs = [run for run in completed_real_runs if run["has_stats"]][:limit]
|
||||||
|
latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
|
||||||
|
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {}
|
||||||
|
|
||||||
|
literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in trend_runs]
|
||||||
|
literal_values = [value for value in literal_values if value is not None]
|
||||||
|
matched_values = [_int_at(run, "rsync", "matched_data_bytes") for run in trend_runs]
|
||||||
|
matched_values = [value for value in matched_values if value is not None]
|
||||||
|
max_literal = max(literal_values) if literal_values else 0
|
||||||
|
max_matched = max(matched_values) if matched_values else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in trend_runs],
|
||||||
|
"latest_run": completed_real_runs[0] if completed_real_runs else {},
|
||||||
|
"latest_good_run": _first_run_with_status(real_runs, {BackupRun.Status.SUCCESS}),
|
||||||
|
"latest_problem_run": _first_run_with_status(real_runs, {BackupRun.Status.WARNING, BackupRun.Status.FAILED}),
|
||||||
|
"latest_snapshot": latest_snapshot_stats,
|
||||||
|
"avg_literal_data_bytes": _average(literal_values),
|
||||||
|
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
|
||||||
|
"total_literal_data_bytes": sum(literal_values),
|
||||||
|
"total_matched_data_bytes": sum(matched_values),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_summary(run: BackupRun) -> dict[str, Any]:
|
||||||
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
|
stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
|
||||||
|
return {
|
||||||
|
"id": run.id,
|
||||||
|
"host": run.host.host,
|
||||||
|
"run_type": run.run_type,
|
||||||
|
"started_at": run.started_at,
|
||||||
|
"ended_at": run.ended_at,
|
||||||
|
"snapshot": run.snapshot,
|
||||||
|
"snapshot_path": run.snapshot_path,
|
||||||
|
"status": run.status,
|
||||||
|
"reviewed_at": run.reviewed_at,
|
||||||
|
"has_stats": bool(stats),
|
||||||
|
"duration_seconds": _int_at(stats, "duration_seconds"),
|
||||||
|
"rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {},
|
||||||
|
"storage": stats.get("storage") if isinstance(stats.get("storage"), dict) else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
||||||
|
if snapshot is None:
|
||||||
|
return {}
|
||||||
|
metadata = snapshot.metadata if isinstance(snapshot.metadata, dict) else {}
|
||||||
|
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
|
||||||
|
storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {}
|
||||||
|
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {}
|
||||||
|
return {
|
||||||
|
"id": snapshot.id,
|
||||||
|
"dirname": snapshot.dirname,
|
||||||
|
"kind": snapshot.kind,
|
||||||
|
"status": snapshot.status,
|
||||||
|
"started_at": snapshot.started_at,
|
||||||
|
"apparent_size_bytes": _int_at(snapshot_storage, "apparent_size_bytes"),
|
||||||
|
"allocated_size_bytes": _int_at(snapshot_storage, "allocated_size_bytes"),
|
||||||
|
"hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_real_run(run: BackupRun) -> bool:
|
||||||
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
|
if result.get("dry_run") is True:
|
||||||
|
return False
|
||||||
|
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||||
|
return requested.get("dry_run") is not True
|
||||||
|
|
||||||
|
|
||||||
|
def _first_run_with_status(runs: list[dict[str, Any]], statuses: set[str]) -> dict[str, Any]:
|
||||||
|
for run in runs:
|
||||||
|
if run["status"] in statuses and run.get("reviewed_at") is None:
|
||||||
|
return run
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _capacity_from_system(global_config: GlobalConfig | None) -> dict[str, Any]:
|
||||||
|
if global_config is None or not global_config.backup_root:
|
||||||
|
return {}
|
||||||
|
return filesystem_capacity(Path(global_config.backup_root))
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_capacity_from_runs(runs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
for run in runs:
|
||||||
|
capacity = _dict_at(run, "storage", "capacity")
|
||||||
|
if capacity:
|
||||||
|
return capacity
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _average(values: list[int]) -> int | None:
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
return int(sum(values) / len(values))
|
||||||
|
|
||||||
|
|
||||||
|
def _average_daily_literal(runs: list[dict[str, Any]]) -> int | None:
|
||||||
|
values = [_int_at(run, "rsync", "literal_data_bytes") for run in runs]
|
||||||
|
values = [value for value in values if value is not None]
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
timestamps = [run["started_at"] for run in runs if run.get("started_at") is not None]
|
||||||
|
if len(timestamps) < 2:
|
||||||
|
return _average(values)
|
||||||
|
|
||||||
|
oldest = min(timestamps)
|
||||||
|
newest = max(timestamps)
|
||||||
|
if timezone.is_naive(oldest):
|
||||||
|
oldest = timezone.make_aware(oldest)
|
||||||
|
if timezone.is_naive(newest):
|
||||||
|
newest = timezone.make_aware(newest)
|
||||||
|
span_days = max((newest - oldest).total_seconds() / 86400, 1)
|
||||||
|
return int(sum(values) / span_days)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_bar_percentages(run: dict[str, Any], *, max_literal: int, max_matched: int) -> dict[str, Any]:
|
||||||
|
run = dict(run)
|
||||||
|
literal = _int_at(run, "rsync", "literal_data_bytes") or 0
|
||||||
|
matched = _int_at(run, "rsync", "matched_data_bytes") or 0
|
||||||
|
run["literal_percent"] = _percentage(literal, max_literal)
|
||||||
|
run["matched_percent"] = _percentage(matched, max_matched)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _percentage(value: int, maximum: int) -> int:
|
||||||
|
if maximum <= 0 or value <= 0:
|
||||||
|
return 0
|
||||||
|
return max(1, min(100, int(value / maximum * 100)))
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_at(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
||||||
|
value: Any = data
|
||||||
|
for key in keys:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
value = value.get(key)
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _int_at(data: dict[str, Any], *keys: str) -> int | None:
|
||||||
|
value: Any = data
|
||||||
|
for key in keys:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
value = value.get(key)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_COMPLETED_BACKUP_STATUSES = [BackupRun.Status.SUCCESS, BackupRun.Status.WARNING]
|
||||||
396
src/pobsync_backend/templates/pobsync_backend/base.html
Normal file
396
src/pobsync_backend/templates/pobsync_backend/base.html
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}pobsync{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f5f7fa;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--border: #d9e0e8;
|
||||||
|
--text: #17202a;
|
||||||
|
--muted: #657386;
|
||||||
|
--link: #0b5cad;
|
||||||
|
--success: #176b3a;
|
||||||
|
--failed: #a12828;
|
||||||
|
--running: #8a5a00;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
a { color: var(--link); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
header {
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 14px 24px;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
nav strong { font-size: 16px; }
|
||||||
|
nav .spacer { flex: 1; }
|
||||||
|
main {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 26px; margin: 0 0 18px; }
|
||||||
|
h2 { font-size: 18px; margin: 0 0 12px; }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.metric, .panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.metric { padding: 14px; }
|
||||||
|
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||||
|
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
|
||||||
|
.metric.failed { border-color: #e8b4b4; background: #fff7f7; }
|
||||||
|
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
|
||||||
|
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
|
||||||
|
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
|
||||||
|
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
|
||||||
|
.panel.highlight { border-left: 4px solid var(--border); }
|
||||||
|
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
|
||||||
|
.panel.highlight.warning { border-left-color: var(--running); background: #fffaf0; }
|
||||||
|
.panel.highlight.success { border-left-color: var(--success); background: #f5fbf7; }
|
||||||
|
table { width: 100%; border-collapse: collapse; min-width: 640px; }
|
||||||
|
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
|
||||||
|
th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
|
||||||
|
.status.ok { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
|
||||||
|
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
|
||||||
|
.status.blocked { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
|
||||||
|
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
||||||
|
.status.warning { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
||||||
|
.status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; }
|
||||||
|
.status.skipped { color: var(--muted); background: #f7f9fb; }
|
||||||
|
.stack { display: grid; gap: 4px; }
|
||||||
|
.stack.spaced { margin-bottom: 14px; }
|
||||||
|
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
|
||||||
|
.actions.inline { margin: 12px 0 0; }
|
||||||
|
button, .button-link {
|
||||||
|
appearance: none;
|
||||||
|
background: #17202a;
|
||||||
|
border: 1px solid #17202a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
|
||||||
|
button.secondary,
|
||||||
|
.button-link.secondary {
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
button.secondary:hover,
|
||||||
|
.button-link.secondary:hover { background: #eef3f8; }
|
||||||
|
button:disabled {
|
||||||
|
background: #d8dee6;
|
||||||
|
border-color: #d8dee6;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.inline-form { margin: 0; }
|
||||||
|
.status-overview {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.status-summary {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
|
||||||
|
.status-summary.warning,
|
||||||
|
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
|
||||||
|
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
|
||||||
|
.operator-state {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.trend-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
.trend-bar {
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.trend-bar span {
|
||||||
|
background: var(--link);
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.trend-bar.matched span { background: var(--success); }
|
||||||
|
.trend-legend {
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.insight-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px 24px;
|
||||||
|
grid-template-columns: minmax(260px, 1.3fr) repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.insight-main,
|
||||||
|
.insight-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.insight-main .label,
|
||||||
|
.insight-item .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.insight-main .value,
|
||||||
|
.insight-item .value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.storage-meter {
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 10px;
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.storage-meter span {
|
||||||
|
background: var(--link);
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.host-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.host-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.host-card-header {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.host-card-title {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.host-card-title a {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.host-card-status {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
.host-card-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
||||||
|
}
|
||||||
|
.host-card-section {
|
||||||
|
align-content: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.host-card-section-title {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.host-card-timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px 22px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
|
}
|
||||||
|
.host-card-stats {
|
||||||
|
align-content: start;
|
||||||
|
display: grid;
|
||||||
|
border-top: 1px solid #e6edf4;
|
||||||
|
gap: 12px 18px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.host-card-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.host-card-item .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.host-card-item .value {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.host-card-stat {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.host-card-stat .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.host-card-stat .value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.host-card-stat.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.host-card-warning {
|
||||||
|
background: #fffaf0;
|
||||||
|
border: 1px solid #e7cf8a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--running);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
||||||
|
.message {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
|
||||||
|
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
|
||||||
|
.message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); }
|
||||||
|
.form-grid { display: grid; gap: 14px; max-width: 680px; }
|
||||||
|
.field { display: grid; gap: 5px; }
|
||||||
|
.field label { font-weight: 650; }
|
||||||
|
.field input[type="text"], .field input[type="number"], .field select, .field textarea {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.field textarea { min-height: 92px; resize: vertical; }
|
||||||
|
.field .helptext { color: var(--muted); font-size: 12px; }
|
||||||
|
.field input[type="checkbox"] { justify-self: start; }
|
||||||
|
pre {
|
||||||
|
background: #101820;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #edf4fb;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.errorlist {
|
||||||
|
color: var(--failed);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
main { padding: 16px; }
|
||||||
|
nav { padding: 0; }
|
||||||
|
.two-col { grid-template-columns: 1fr; }
|
||||||
|
.host-card-header { display: grid; }
|
||||||
|
.host-card-status { justify-content: flex-start; max-width: none; }
|
||||||
|
.host-card-layout { grid-template-columns: 1fr; }
|
||||||
|
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||||
|
.insight-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
||||||
|
<a href="{% url 'admin:index' %}">Admin</a>
|
||||||
|
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
||||||
|
<a href="{% url 'self_check' %}">Self Check</a>
|
||||||
|
<a href="{% url 'logs' %}">Logs</a>
|
||||||
|
<a href="{% url 'purged_snapshots' %}">Purged</a>
|
||||||
|
<a href="{% url 'changelog' %}">Changelog</a>
|
||||||
|
<a href="/api/status/">Status API</a>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="muted">{{ request.user.username }}</span>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% if messages %}
|
||||||
|
<section class="messages" aria-label="Messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="message {{ message.tags }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
src/pobsync_backend/templates/pobsync_backend/changelog.html
Normal file
41
src/pobsync_backend/templates/pobsync_backend/changelog.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Changelog - pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Changelog</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Changelog actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Installed version:</strong> {{ app_version }}</div>
|
||||||
|
<div class="muted">Changelog file: {{ changelog_path }}</div>
|
||||||
|
{% if missing %}
|
||||||
|
<div class="status warning">missing</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
{% for block in changelog_blocks %}
|
||||||
|
{% if block.kind == "heading" %}
|
||||||
|
{% if block.level == 1 %}
|
||||||
|
<h2>{{ block.text }}</h2>
|
||||||
|
{% else %}
|
||||||
|
<h3>{{ block.text }}</h3>
|
||||||
|
{% endif %}
|
||||||
|
{% elif block.kind == "list" %}
|
||||||
|
<ul>
|
||||||
|
{% for item in block.items %}
|
||||||
|
<li>{{ item }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ block.text }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
290
src/pobsync_backend/templates/pobsync_backend/dashboard.html
Normal file
290
src/pobsync_backend/templates/pobsync_backend/dashboard.html
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}pobsync dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Dashboard actions">
|
||||||
|
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
|
||||||
|
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if not global_config or not counts.hosts %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Setup</h2>
|
||||||
|
{% if not global_config %}
|
||||||
|
<p class="muted">No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.</p>
|
||||||
|
<div class="actions inline">
|
||||||
|
<a class="button-link" href="{% url 'edit_global_config' %}">Create global config</a>
|
||||||
|
</div>
|
||||||
|
{% elif not counts.hosts %}
|
||||||
|
<p class="muted">Global config is ready. Add the first host to make this dashboard useful.</p>
|
||||||
|
<div class="actions inline">
|
||||||
|
<a class="button-link" href="{% url 'create_host_config' %}">Add first host</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Summary">
|
||||||
|
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||||
|
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||||
|
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||||
|
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
|
||||||
|
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Operational Status</h2>
|
||||||
|
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
|
||||||
|
<div class="status-overview">
|
||||||
|
{% if counts.failed_runs %}
|
||||||
|
<div class="status-summary failed">
|
||||||
|
<span class="status failed">failed</span>
|
||||||
|
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if counts.warning_runs %}
|
||||||
|
<div class="status-summary warning">
|
||||||
|
<span class="status warning">warning</span>
|
||||||
|
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if counts.running_runs %}
|
||||||
|
<div class="status-summary running">
|
||||||
|
<span class="status running">running</span>
|
||||||
|
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if counts.queued_runs %}
|
||||||
|
<div class="status-summary queued">
|
||||||
|
<span class="status queued">queued</span>
|
||||||
|
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif counts.hosts %}
|
||||||
|
<p><span class="status ok">ok</span> No queued, running, or unreviewed warning/failed runs.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Add a host to start tracking backup status here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backup Trends</h2>
|
||||||
|
{% if stats_summary.runs_sampled %}
|
||||||
|
<div class="insight-grid" aria-label="Backup trends">
|
||||||
|
<div class="insight-main">
|
||||||
|
<div class="label">Storage Used</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if stats_summary.capacity.used_percent is not None %}
|
||||||
|
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stats_summary.capacity.used_percent is not None %}
|
||||||
|
<div class="storage-meter" aria-label="Backup root storage usage">
|
||||||
|
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="muted">
|
||||||
|
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Runway</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if stats_summary.estimated_days_until_full %}
|
||||||
|
{{ stats_summary.estimated_days_until_full }} days
|
||||||
|
{% elif stats_summary.estimated_runs_until_full %}
|
||||||
|
{{ stats_summary.estimated_runs_until_full }} runs
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="muted">Estimated from average new data per day.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">New Data</div>
|
||||||
|
<div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</div>
|
||||||
|
<div class="muted">{{ stats_summary.avg_literal_data_bytes|filesizeformat }} per backup on average.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Link-Dest Savings</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if stats_summary.link_dest_savings_percent is not None %}
|
||||||
|
{{ stats_summary.link_dest_savings_percent|floatformat:1 }}%
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="muted">{{ stats_summary.total_matched_data_bytes|filesizeformat }} reused across sampled runs.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Average Duration</div>
|
||||||
|
<div class="value">{{ stats_summary.avg_duration_seconds|default:"unknown" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div>
|
||||||
|
<div class="muted">Based on {{ stats_summary.runs_sampled }} completed backup run{{ stats_summary.runs_sampled|pluralize }} with stats.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No completed backup runs with stats yet. This section will show disk usage, growth estimates, and link-dest savings after the first real backup finishes.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Hosts</h2>
|
||||||
|
<div class="host-list">
|
||||||
|
{% for host in hosts %}
|
||||||
|
<article class="host-card">
|
||||||
|
<div class="host-card-header">
|
||||||
|
<div class="host-card-title">
|
||||||
|
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
|
||||||
|
<span class="muted">{{ host.address }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-status">
|
||||||
|
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
|
||||||
|
{% if host.queued_run_count %}
|
||||||
|
<span class="status queued">queued {{ host.queued_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if host.running_run_count %}
|
||||||
|
<span class="status running">running {{ host.running_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if host.warning_run_count %}
|
||||||
|
<span class="status warning">warning {{ host.warning_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if host.failed_run_count %}
|
||||||
|
<span class="status failed">failed {{ host.failed_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-layout">
|
||||||
|
<div class="host-card-section">
|
||||||
|
<div class="host-card-section-title">Backup activity</div>
|
||||||
|
<div class="host-card-timeline">
|
||||||
|
<div class="host-card-item">
|
||||||
|
<div class="label">Latest Snapshot</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if host.latest_snapshot %}
|
||||||
|
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
|
||||||
|
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-item">
|
||||||
|
<div class="label">Last Good Backup</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if host.stats_summary.latest_good_run.id %}
|
||||||
|
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
|
||||||
|
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-item">
|
||||||
|
<div class="label">Latest Issue</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if host.stats_summary.latest_problem_run.id %}
|
||||||
|
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
|
||||||
|
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
|
||||||
|
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-item">
|
||||||
|
<div class="label">Next Run</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if host.next_run_at %}
|
||||||
|
{{ host.next_run_at|date:"Y-m-d H:i T" }}
|
||||||
|
<div class="muted">{{ scheduler_timezone }}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-section">
|
||||||
|
<div class="host-card-section-title">Snapshot health</div>
|
||||||
|
<div class="host-card-stats">
|
||||||
|
<div class="host-card-stat">
|
||||||
|
<div class="label">Snapshots</div>
|
||||||
|
<div class="value">{{ host.snapshot_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-stat">
|
||||||
|
<div class="label">Runs</div>
|
||||||
|
<div class="value">{{ host.run_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-stat">
|
||||||
|
<div class="label">New Data</div>
|
||||||
|
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-stat">
|
||||||
|
<div class="label">Retention</div>
|
||||||
|
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if host.retention_warning.has_warning %}
|
||||||
|
<div class="host-card-warning">
|
||||||
|
<span class="status warning">retention</span>
|
||||||
|
{% if host.retention_warning.prune_exceeded %}
|
||||||
|
Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}.
|
||||||
|
{% endif %}
|
||||||
|
{% if host.retention_warning.incomplete_count %}
|
||||||
|
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
|
||||||
|
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Mark reviewed</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if host.retention_warning.error %}
|
||||||
|
{{ host.retention_warning.error }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted">No hosts configured yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Latest Runs</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Ended</th>
|
||||||
|
<th>Snapshot</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run in latest_runs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
|
||||||
|
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
||||||
|
<td>{{ run.started_at|default:"" }}</td>
|
||||||
|
<td>{{ run.ended_at|default:"" }}</td>
|
||||||
|
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="muted">No backup runs recorded yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Global Config{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Global config actions">
|
||||||
|
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Backup root:</strong> {{ backup_root }}</div>
|
||||||
|
<div class="muted">This path is managed by the service environment and is saved with the config.</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="field">
|
||||||
|
{{ field.errors }}
|
||||||
|
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save global config</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if config_checks %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Config Check</h2>
|
||||||
|
<section class="grid" aria-label="Global config check summary">
|
||||||
|
<div class="metric"><div class="label">OK</div><div class="value">{{ config_check_summary.ok }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Warnings</div><div class="value">{{ config_check_summary.warning }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Failed</div><div class="value">{{ config_check_summary.failed }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Skipped</div><div class="value">{{ config_check_summary.skipped }}</div></div>
|
||||||
|
</section>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Check</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for check in config_checks %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
|
||||||
|
<td>{{ check.name }}</td>
|
||||||
|
<td>{{ check.message }}</td>
|
||||||
|
<td class="muted">{{ check.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
384
src/pobsync_backend/templates/pobsync_backend/host_detail.html
Normal file
384
src/pobsync_backend/templates/pobsync_backend/host_detail.html
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ host.host }} | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Host actions">
|
||||||
|
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||||
|
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit">Discover snapshots</button>
|
||||||
|
</form>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||||
|
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
|
||||||
|
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Prepare directories</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Scan SSH host key</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'run_host_preflight' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Run connection preflight</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Host summary">
|
||||||
|
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Config</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Address:</strong> {{ host.address }}</div>
|
||||||
|
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
|
||||||
|
<div><strong>SSH key:</strong> {{ host.ssh_credential|default:"global default" }}</div>
|
||||||
|
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
|
||||||
|
<div><strong>Backup source:</strong> {{ host.source_root|default:"global default" }}</div>
|
||||||
|
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Schedule</h2>
|
||||||
|
{% if schedule %}
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div>
|
||||||
|
<div class="muted">Evaluated by the pobsync scheduler service.</div>
|
||||||
|
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
|
||||||
|
<div><strong>Next run:</strong> {% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }} <span class="muted">{{ scheduler_timezone }}</span>{% endif %}</div>
|
||||||
|
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
|
||||||
|
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
|
||||||
|
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
|
||||||
|
<div><strong>Last finished:</strong> {{ schedule.last_finished_at|default:"" }}</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No schedule configured.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if retention_warning.has_warning %}
|
||||||
|
<section class="panel highlight warning">
|
||||||
|
<h2>Retention Warnings</h2>
|
||||||
|
<div class="stack">
|
||||||
|
{% if retention_warning.prune_exceeded %}
|
||||||
|
<div>
|
||||||
|
Scheduled pruning would delete {{ retention_warning.delete_count }} snapshot(s), above max delete
|
||||||
|
{{ retention_warning.max_delete }}. Scheduled pruning will refuse this plan until the limit or retention
|
||||||
|
selection is adjusted.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if retention_warning.incomplete_count %}
|
||||||
|
<div>
|
||||||
|
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
|
||||||
|
snapshots automatically; inspect them before cleanup.
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Mark incomplete reviewed</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if retention_warning.error %}
|
||||||
|
<div>{{ retention_warning.error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if effective_config %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Effective Config</h2>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Backup source:</strong> {{ effective_config.source_root }}</div>
|
||||||
|
<div><strong>Destination subdir:</strong> {{ effective_config.destination_subdir|default:"none" }}</div>
|
||||||
|
<div><strong>SSH:</strong> {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</div>
|
||||||
|
<div><strong>SSH key:</strong> {{ effective_config.ssh.credential|default:"none selected" }}</div>
|
||||||
|
<div><strong>SSH options:</strong> {{ effective_config.ssh.options|join:" " }}</div>
|
||||||
|
<div><strong>Rsync binary:</strong> {{ effective_config.rsync.binary }}</div>
|
||||||
|
<div><strong>Rsync args:</strong> {{ effective_config.rsync.args|join:" " }}</div>
|
||||||
|
<div><strong>Timeout:</strong> {{ effective_config.rsync.timeout_seconds }}s</div>
|
||||||
|
<div><strong>Bandwidth limit:</strong> {{ effective_config.rsync.bwlimit_kbps }} KB/s</div>
|
||||||
|
<div>
|
||||||
|
<strong>Retention:</strong>
|
||||||
|
d{{ effective_config.retention.daily }}
|
||||||
|
w{{ effective_config.retention.weekly }}
|
||||||
|
m{{ effective_config.retention.monthly }}
|
||||||
|
y{{ effective_config.retention.yearly }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Includes:</strong> {{ effective_config.includes|length }}</div>
|
||||||
|
{% if effective_config.includes %}
|
||||||
|
<pre>{{ effective_config.includes|join:" " }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No include rules configured.</div>
|
||||||
|
{% endif %}
|
||||||
|
<div><strong>Excludes:</strong> {{ effective_config.excludes|length }}</div>
|
||||||
|
{% if effective_config.excludes %}
|
||||||
|
<pre>{{ effective_config.excludes|join:" " }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No exclude rules configured.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Snapshot Discovery</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Backup root:</strong> {{ discovery.backup_root|default:"" }}</div>
|
||||||
|
<div><strong>Host root:</strong> {{ discovery.host_root|default:"" }}</div>
|
||||||
|
<div><strong>Status:</strong> {{ discovery.message }}</div>
|
||||||
|
{% if discovery.kind_counts %}
|
||||||
|
<div><strong>On disk:</strong>
|
||||||
|
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
|
||||||
|
manual {{ discovery.kind_counts.manual|default:0 }},
|
||||||
|
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if stats_summary.runs %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backup Trends</h2>
|
||||||
|
<section class="grid" aria-label="Host backup trend summary">
|
||||||
|
<div class="metric"><div class="label">Avg New Data</div><div class="value">{{ stats_summary.avg_literal_data_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Avg Daily New</div><div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Total New Data</div><div class="value">{{ stats_summary.total_literal_data_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Matched Data</div><div class="value">{{ stats_summary.total_matched_data_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Latest Duration</div><div class="value">{{ stats_summary.latest_run.duration_seconds|default:"" }}{% if stats_summary.latest_run.duration_seconds is not None %}s{% endif %}</div></div>
|
||||||
|
</section>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Run</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th>New Data</th>
|
||||||
|
<th>Matched</th>
|
||||||
|
<th>Trend</th>
|
||||||
|
<th>Snapshot</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run in stats_summary.runs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
|
||||||
|
<td>{{ run.run_type }}</td>
|
||||||
|
<td>{{ run.started_at|default:"" }}</td>
|
||||||
|
<td>{{ run.duration_seconds|default:"" }}{% if run.duration_seconds is not None %}s{% endif %}</td>
|
||||||
|
<td>{{ run.rsync.files_total|default:"" }}</td>
|
||||||
|
<td>{{ run.rsync.literal_data_bytes|filesizeformat }}</td>
|
||||||
|
<td>{{ run.rsync.matched_data_bytes|filesizeformat }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="trend-bars" aria-label="Run data trend">
|
||||||
|
<div class="trend-bar" title="New data"><span style="width: {{ run.literal_percent }}%"></span></div>
|
||||||
|
<div class="trend-bar matched" title="Matched data"><span style="width: {{ run.matched_percent }}%"></span></div>
|
||||||
|
<div class="trend-legend"><span>new</span><span>matched</span></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Host Check</h2>
|
||||||
|
<section class="grid" aria-label="Host check summary">
|
||||||
|
<div class="metric"><div class="label">OK</div><div class="value">{{ host_check_summary.ok }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Warnings</div><div class="value">{{ host_check_summary.warning }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
|
||||||
|
</section>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Check</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for check in host_checks %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
|
||||||
|
<td>{{ check.name }}</td>
|
||||||
|
<td>{{ check.message }}</td>
|
||||||
|
<td class="muted">{{ check.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if last_preflight %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Connection Preflight</h2>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Status:</strong> <span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">{% if last_preflight.ok %}ok{% else %}failed{% endif %}</span></div>
|
||||||
|
<div><strong>Target:</strong> {{ last_preflight.target }}</div>
|
||||||
|
<div><strong>Backup source:</strong> {{ last_preflight.source_root }}</div>
|
||||||
|
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Check</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for check in last_preflight.checks %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="status {% if check.ok %}ok{% else %}failed{% endif %}">{% if check.ok %}ok{% else %}failed{% endif %}</span></td>
|
||||||
|
<td>{{ check.name }}</td>
|
||||||
|
<td>{{ check.message }}</td>
|
||||||
|
<td class="muted">{{ check.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backup Control</h2>
|
||||||
|
<div class="operator-state">
|
||||||
|
{% if active_run %}
|
||||||
|
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||||
|
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
||||||
|
{% elif has_global_config and host.enabled %}
|
||||||
|
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
|
||||||
|
<span class="muted">{{ backup_gate.message }}</span>
|
||||||
|
{% elif not host.enabled %}
|
||||||
|
<span class="status failed">disabled</span>
|
||||||
|
{% elif not has_global_config %}
|
||||||
|
<span class="status failed">missing global config</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="actions inline" aria-label="Quick backup actions">
|
||||||
|
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="dry_run" value="on">
|
||||||
|
<input type="hidden" name="verbose_output" value="on">
|
||||||
|
<input type="hidden" name="prune_max_delete" value="10">
|
||||||
|
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
|
||||||
|
</form>
|
||||||
|
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="prune_max_delete" value="10">
|
||||||
|
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if active_run %}
|
||||||
|
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
|
||||||
|
{% elif not can_queue_dry_run or not can_queue_real_backup %}
|
||||||
|
{% if not has_global_config %}
|
||||||
|
<p class="muted">Create the default global config before queueing backups.</p>
|
||||||
|
{% elif not host.enabled %}
|
||||||
|
<p class="muted">Enable this host before queueing backups.</p>
|
||||||
|
{% elif backup_gate.real_blockers %}
|
||||||
|
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>Advanced Options</h3>
|
||||||
|
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ manual_backup_form.non_field_errors }}
|
||||||
|
|
||||||
|
{% for field in manual_backup_form %}
|
||||||
|
<div class="field">
|
||||||
|
{{ field.errors }}
|
||||||
|
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Latest Runs</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Ended</th>
|
||||||
|
<th>Snapshot</th>
|
||||||
|
<th>Base</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run in latest_runs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
||||||
|
<td>{{ run.started_at|default:"" }}</td>
|
||||||
|
<td>{{ run.ended_at|default:"" }}</td>
|
||||||
|
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||||
|
<td>{{ run.base_path|default:"" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="muted">No backup runs recorded for this host.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Snapshots</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Base</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snapshot.kind }}</td>
|
||||||
|
<td>{{ snapshot.status }}</td>
|
||||||
|
<td>{{ snapshot.started_at|default:"" }}</td>
|
||||||
|
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
|
||||||
|
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
68
src/pobsync_backend/templates/pobsync_backend/host_form.html
Normal file
68
src/pobsync_backend/templates/pobsync_backend/host_form.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Config actions">
|
||||||
|
{% if host %}
|
||||||
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="field">
|
||||||
|
{{ field.errors }}
|
||||||
|
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">{% if host %}Save config{% else %}Create host{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if config_checks %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Effective Config Check</h2>
|
||||||
|
<section class="grid" aria-label="Host config check summary">
|
||||||
|
<div class="metric"><div class="label">OK</div><div class="value">{{ config_check_summary.ok }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Warnings</div><div class="value">{{ config_check_summary.warning }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Failed</div><div class="value">{{ config_check_summary.failed }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Skipped</div><div class="value">{{ config_check_summary.skipped }}</div></div>
|
||||||
|
</section>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Check</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for check in config_checks %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
|
||||||
|
<td>{{ check.name }}</td>
|
||||||
|
<td>{{ check.message }}</td>
|
||||||
|
<td class="muted">{{ check.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
68
src/pobsync_backend/templates/pobsync_backend/logs.html
Normal file
68
src/pobsync_backend/templates/pobsync_backend/logs.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Logs | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Logs</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Log actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filter</h2>
|
||||||
|
<form method="get" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="unit">Unit</label>
|
||||||
|
<select id="unit" name="unit">
|
||||||
|
<option value="">All pobsync units</option>
|
||||||
|
{% for unit in units %}
|
||||||
|
<option value="{{ unit }}" {% if selected_unit == unit %}selected{% endif %}>{{ unit }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="priority">Priority</label>
|
||||||
|
<select id="priority" name="priority">
|
||||||
|
{% for value, label in priorities.items %}
|
||||||
|
<option value="{{ value }}" {% if selected_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="window">Time window</label>
|
||||||
|
<select id="window" name="window">
|
||||||
|
{% for value, label in time_windows.items %}
|
||||||
|
<option value="{{ value }}" {% if selected_window == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">Host contains</label>
|
||||||
|
<input id="host" name="host" value="{{ host_filter }}" placeholder="web-01.example.test">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="run">Run</label>
|
||||||
|
<input id="run" name="run" value="{{ run_filter }}" inputmode="numeric" placeholder="12">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="q">Message contains</label>
|
||||||
|
<input id="q" name="q" value="{{ query }}">
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Filter logs</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Messages</h2>
|
||||||
|
{% if error %}
|
||||||
|
<p class="status failed">{{ error }}</p>
|
||||||
|
{% else %}
|
||||||
|
<pre>{% for line in lines %}{{ line }}
|
||||||
|
{% empty %}No log messages matched the current filter.
|
||||||
|
{% endfor %}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Purged Snapshots | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Purged Snapshots</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Purged snapshot actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<form method="get" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<select id="host" name="host">
|
||||||
|
<option value="">All hosts</option>
|
||||||
|
{% for host in hosts %}
|
||||||
|
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="action">Action</label>
|
||||||
|
<select id="action" name="action">
|
||||||
|
<option value="">All actions</option>
|
||||||
|
{% for value, label in actions %}
|
||||||
|
<option value="{{ value }}" {% if selected_action == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Apply filters</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'purged_snapshots' %}">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Purged Snapshot History</h2>
|
||||||
|
<p class="muted">Showing up to 200 of {{ total_count }} purged snapshot record(s).</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Purged</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Triggered by</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in purged_snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snapshot.purged_at }}</td>
|
||||||
|
<td>{% if snapshot.host %}<a href="{% url 'host_detail' snapshot.host.host %}">{{ snapshot.host_name }}</a>{% else %}{{ snapshot.host_name }}{% endif %}</td>
|
||||||
|
<td>{{ snapshot.kind }}</td>
|
||||||
|
<td>{{ snapshot.dirname }}</td>
|
||||||
|
<td><span class="status skipped">{{ snapshot.get_action_display }}</span></td>
|
||||||
|
<td>{{ snapshot.reason|default:"" }}</td>
|
||||||
|
<td>{{ snapshot.triggered_by|default:"" }}</td>
|
||||||
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="8" class="muted">No purged snapshots recorded yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Retention plan | {{ host.host }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Retention Plan: {{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Retention filters">
|
||||||
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=manual">Manual</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Retention plan summary">
|
||||||
|
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Keep</div><div class="value">{{ plan.keep|length }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Would Delete</div><div class="value">{{ plan.delete|length }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Scheduled Limit</div><div class="value">{{ scheduled_prune_limit|default:"none" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ plan.incomplete|length }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if scheduled_prune_exceeded %}
|
||||||
|
<section class="panel highlight warning">
|
||||||
|
<h2>Scheduled Prune Limit</h2>
|
||||||
|
<p>
|
||||||
|
This plan would delete {{ plan.delete|length }} snapshot(s), which exceeds the scheduled prune limit of
|
||||||
|
{{ scheduled_prune_limit }}. Scheduled pruning will refuse to apply this plan until the limit or retention
|
||||||
|
selection is adjusted.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if plan.incomplete %}
|
||||||
|
<section class="panel highlight warning">
|
||||||
|
<h2>Incomplete Snapshots</h2>
|
||||||
|
<p>
|
||||||
|
{{ plan.incomplete|length }} incomplete snapshot(s) exist for this host. Retention does not delete incomplete
|
||||||
|
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their
|
||||||
|
tracking records. Successful scheduled and manual snapshots are not touched by this cleanup.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Policy</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Daily:</strong> {{ plan.retention.daily }}</div>
|
||||||
|
<div><strong>Weekly:</strong> {{ plan.retention.weekly }}</div>
|
||||||
|
<div><strong>Monthly:</strong> {{ plan.retention.monthly }}</div>
|
||||||
|
<div><strong>Yearly:</strong> {{ plan.retention.yearly }}</div>
|
||||||
|
<div><strong>Protect bases:</strong> {{ protect_bases|yesno:"yes,no" }}</div>
|
||||||
|
<div class="muted">
|
||||||
|
{% if protect_bases %}
|
||||||
|
Base snapshots referenced by kept snapshots are also kept and marked with a base-of reason.
|
||||||
|
{% else %}
|
||||||
|
Base snapshots are only kept when they match the regular retention policy.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if schedule %}
|
||||||
|
<div><strong>Schedule pruning:</strong> {{ schedule.prune|yesno:"enabled,disabled" }}</div>
|
||||||
|
<div><strong>Schedule max delete:</strong> {{ schedule.prune_max_delete }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Would Delete</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in plan.delete %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snapshot.kind }}</td>
|
||||||
|
<td>{{ snapshot.dirname }}</td>
|
||||||
|
<td>{{ snapshot.dt }}</td>
|
||||||
|
<td>{{ snapshot.status|default:"" }}</td>
|
||||||
|
<td>{{ snapshot.reason }}</td>
|
||||||
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="6" class="muted">Retention would not delete snapshots for this selection.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if plan.delete %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Apply Retention</h2>
|
||||||
|
<form method="post" action="{% url 'apply_host_retention' host.host %}" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ apply_form.non_field_errors }}
|
||||||
|
{{ apply_form.kind.as_hidden }}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ apply_form.max_delete.errors }}
|
||||||
|
<label for="{{ apply_form.max_delete.id_for_label }}">Max delete</label>
|
||||||
|
{{ apply_form.max_delete }}
|
||||||
|
<div class="helptext">Must be at least {{ plan.delete|length }} for the snapshots shown in Would Delete.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ apply_form.protect_bases.errors }}
|
||||||
|
<label for="{{ apply_form.protect_bases.id_for_label }}">Protect bases</label>
|
||||||
|
{{ apply_form.protect_bases }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ apply_form.confirm_host.errors }}
|
||||||
|
<label for="{{ apply_form.confirm_host.id_for_label }}">Confirm host</label>
|
||||||
|
{{ apply_form.confirm_host }}
|
||||||
|
<div class="helptext">{{ apply_form.confirm_host.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ apply_form.confirm_delete_count.errors }}
|
||||||
|
<label for="{{ apply_form.confirm_delete_count.id_for_label }}">Confirm delete count</label>
|
||||||
|
{{ apply_form.confirm_delete_count }}
|
||||||
|
<div class="helptext">{{ apply_form.confirm_delete_count.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Apply retention</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Keep Reasons</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reasons</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in plan.keep_items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snapshot.kind }}</td>
|
||||||
|
<td>{{ snapshot.dirname }}</td>
|
||||||
|
<td>{{ snapshot.dt }}</td>
|
||||||
|
<td>{{ snapshot.status|default:"" }}</td>
|
||||||
|
<td>{{ snapshot.reason }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="muted">No snapshots matched this retention selection.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if plan.incomplete %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Incomplete Snapshots</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in plan.incomplete %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snapshot.dirname }}</td>
|
||||||
|
<td>{{ snapshot.dt }}</td>
|
||||||
|
<td>{{ snapshot.status|default:"" }}</td>
|
||||||
|
<td>{{ snapshot.reason }}</td>
|
||||||
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Cleanup Incomplete Snapshots</h3>
|
||||||
|
<form method="post" action="{% url 'cleanup_host_incomplete_snapshots' host.host %}" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ incomplete_cleanup_form.non_field_errors }}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ incomplete_cleanup_form.max_delete.errors }}
|
||||||
|
<label for="{{ incomplete_cleanup_form.max_delete.id_for_label }}">Max delete</label>
|
||||||
|
{{ incomplete_cleanup_form.max_delete }}
|
||||||
|
<div class="helptext">{{ incomplete_cleanup_form.max_delete.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ incomplete_cleanup_form.confirm_host.errors }}
|
||||||
|
<label for="{{ incomplete_cleanup_form.confirm_host.id_for_label }}">Confirm host</label>
|
||||||
|
{{ incomplete_cleanup_form.confirm_host }}
|
||||||
|
<div class="helptext">{{ incomplete_cleanup_form.confirm_host.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ incomplete_cleanup_form.confirm_delete_count.errors }}
|
||||||
|
<label for="{{ incomplete_cleanup_form.confirm_delete_count.id_for_label }}">Confirm incomplete count</label>
|
||||||
|
{{ incomplete_cleanup_form.confirm_delete_count }}
|
||||||
|
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Delete incomplete snapshots</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
243
src/pobsync_backend/templates/pobsync_backend/run_detail.html
Normal file
243
src/pobsync_backend/templates/pobsync_backend/run_detail.html
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Run {{ run.id }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Run actions">
|
||||||
|
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
|
||||||
|
{% if can_cancel %}
|
||||||
|
<form method="post" action="{% url 'cancel_run' run.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Cancel run</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if run.status == "failed" or run.status == "warning" %}
|
||||||
|
{% if not run.reviewed_at %}
|
||||||
|
<form method="post" action="{% url 'resolve_run_review' run.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Mark reviewed</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Run summary">
|
||||||
|
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
|
||||||
|
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if failure %}
|
||||||
|
<section class="panel highlight failed">
|
||||||
|
<h2>Failure</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
||||||
|
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
||||||
|
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if run.reviewed_at %}
|
||||||
|
<section class="panel highlight success">
|
||||||
|
<h2>Review</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
|
||||||
|
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if dry_run_summary %}
|
||||||
|
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
||||||
|
<h2>Dry Run Summary</h2>
|
||||||
|
<section class="grid" aria-label="Dry run summary">
|
||||||
|
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
|
||||||
|
</section>
|
||||||
|
<div class="stack">
|
||||||
|
{% if dry_run_summary.duration_seconds is not None %}
|
||||||
|
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>Log:</strong>
|
||||||
|
{% if dry_run_summary.log_available %}
|
||||||
|
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||||
|
{% elif rsync_log_path %}
|
||||||
|
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">not recorded yet</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if dry_run_summary.warnings %}
|
||||||
|
<div><strong>Warnings:</strong></div>
|
||||||
|
<ul>
|
||||||
|
{% for warning in dry_run_summary.warnings %}
|
||||||
|
<li>{{ warning }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div><strong>Warnings:</strong> none recorded</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Timing</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Created:</strong> {{ run.created_at }}</div>
|
||||||
|
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
|
||||||
|
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
|
||||||
|
{% if execution %}
|
||||||
|
<div><strong>Worker:</strong> {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}</div>
|
||||||
|
<div><strong>Worker heartbeat:</strong> {{ execution.heartbeat_at|default:"" }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Snapshot</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
|
||||||
|
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
|
||||||
|
<div>
|
||||||
|
<strong>Rsync log:</strong>
|
||||||
|
{% if rsync_log_exists %}
|
||||||
|
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
|
||||||
|
{% elif rsync_log_path %}
|
||||||
|
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if requested %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Requested Options</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Dry run:</strong> {{ requested.dry_run|yesno:"yes,no" }}</div>
|
||||||
|
<div><strong>Verbose rsync output:</strong> {{ requested.verbose_output|yesno:"yes,no" }}</div>
|
||||||
|
<div><strong>Apply retention:</strong> {{ requested.prune|yesno:"yes,no" }}</div>
|
||||||
|
<div><strong>Retention max delete:</strong> {{ requested.prune_max_delete }}</div>
|
||||||
|
<div><strong>Protect bases:</strong> {{ requested.prune_protect_bases|yesno:"yes,no" }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Rsync Command</h2>
|
||||||
|
{% if rsync_command %}
|
||||||
|
<pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
|
||||||
|
{% endif %}{% endfor %}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No rsync command recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Rsync Log</h2>
|
||||||
|
<div class="stack spaced">
|
||||||
|
{% if rsync_log_exists %}
|
||||||
|
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
||||||
|
<div class="muted">{{ rsync_log_path }}</div>
|
||||||
|
{% elif rsync_log_path %}
|
||||||
|
<div class="muted">{{ rsync_log_path }} (missing)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No rsync log path recorded yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if rsync_log_tail %}
|
||||||
|
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
|
||||||
|
{% endif %}{% endfor %}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No recent rsync log output recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if stats %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Stats</h2>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Duration:</strong> {{ stats.duration_seconds|default:"" }}{% if stats.duration_seconds is not None %}s{% endif %}</div>
|
||||||
|
<div><strong>Files seen:</strong> {{ stats.rsync.files_total|default:"" }}</div>
|
||||||
|
<div><strong>Files transferred:</strong> {{ stats.rsync.files_transferred|default:"" }}</div>
|
||||||
|
<div><strong>Files created:</strong> {{ stats.rsync.files_created|default:"" }}</div>
|
||||||
|
<div><strong>Files deleted:</strong> {{ stats.rsync.files_deleted|default:"" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Total file size:</strong> {{ stats.rsync.total_file_size_bytes|filesizeformat }}</div>
|
||||||
|
<div><strong>Transferred file size:</strong> {{ stats.rsync.total_transferred_file_size_bytes|filesizeformat }}</div>
|
||||||
|
<div><strong>Literal data:</strong> {{ stats.rsync.literal_data_bytes|filesizeformat }}</div>
|
||||||
|
<div><strong>Matched data:</strong> {{ stats.rsync.matched_data_bytes|filesizeformat }}</div>
|
||||||
|
<div><strong>Estimated link-dest saving:</strong> {{ stats.rsync.link_dest_estimated_savings_bytes|filesizeformat }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_prune_result %}
|
||||||
|
<section class="panel highlight {% if prune_result.ok %}success{% else %}warning{% endif %}">
|
||||||
|
<h2>Retention</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Status:</strong> {% if prune_result.ok %}ok{% else %}warning{% endif %}</div>
|
||||||
|
{% if prune_result.kind %}<div><strong>Kind:</strong> {{ prune_result.kind }}</div>{% endif %}
|
||||||
|
{% if prune_result.planned_delete_count is not None %}<div><strong>Planned deletions:</strong> {{ prune_result.planned_delete_count }}</div>{% endif %}
|
||||||
|
{% if prune_result.deleted %}<div><strong>Deleted:</strong> {{ prune_result.deleted|length }}</div>{% endif %}
|
||||||
|
{% if prune_result.max_delete is not None %}<div><strong>Max delete:</strong> {{ prune_result.max_delete }}</div>{% endif %}
|
||||||
|
{% if prune_result.protect_bases is not None %}<div><strong>Protect bases:</strong> {{ prune_result.protect_bases|yesno:"yes,no" }}</div>{% endif %}
|
||||||
|
{% if prune_result.incomplete_ignored_count %}<div><strong>Incomplete ignored:</strong> {{ prune_result.incomplete_ignored_count }}</div>{% endif %}
|
||||||
|
{% if prune_result.actions %}
|
||||||
|
<div><strong>Actions:</strong></div>
|
||||||
|
<ul>
|
||||||
|
{% for action in prune_result.actions %}
|
||||||
|
<li>{{ action }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% if prune_result.error %}<div><strong>Error:</strong> {{ prune_result.error }}</div>{% endif %}
|
||||||
|
{% if prune_result.type %}<div><strong>Type:</strong> {{ prune_result.type }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if retention_warning.has_warning %}
|
||||||
|
<section class="panel highlight warning">
|
||||||
|
<h2>Retention Warnings</h2>
|
||||||
|
<div class="stack">
|
||||||
|
{% if retention_warning.prune_exceeded %}
|
||||||
|
<div>
|
||||||
|
Scheduled pruning for this host would delete {{ retention_warning.delete_count }} snapshot(s), above max
|
||||||
|
delete {{ retention_warning.max_delete }}.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if retention_warning.incomplete_count %}
|
||||||
|
<div>
|
||||||
|
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist for this host and are excluded from
|
||||||
|
retention cleanup.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if retention_warning.error %}
|
||||||
|
<div>{{ retention_warning.error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Raw Result</h2>
|
||||||
|
<pre>{{ result_json }}</pre>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Schedule | {{ host.host }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Schedule: {{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Schedule actions">
|
||||||
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2>
|
||||||
|
<p class="muted">Schedules use cron-style timing syntax, but they are evaluated by the pobsync scheduler service.</p>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="field">
|
||||||
|
{{ field.errors }}
|
||||||
|
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save schedule</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Self Check | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Self Check</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Self check actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Self check summary">
|
||||||
|
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Warnings</div><div class="value">{{ summary.warning }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Failed</div><div class="value">{{ summary.failed }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Skipped</div><div class="value">{{ summary.skipped }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Checks</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Check</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for check in checks %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
|
||||||
|
<td>{{ check.name }}</td>
|
||||||
|
<td>{{ check.message }}</td>
|
||||||
|
<td class="muted">{{ check.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ snapshot.dirname }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Snapshot actions">
|
||||||
|
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Snapshot summary">
|
||||||
|
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Kind</div><div class="value">{{ snapshot.kind }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Status</div><div class="value">{{ snapshot.status|default:"" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Runs</div><div class="value">{{ backup_runs|length }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Snapshot</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Path:</strong> {{ snapshot.path }}</div>
|
||||||
|
<div><strong>Started:</strong> {{ snapshot.started_at|default:"" }}</div>
|
||||||
|
<div><strong>Ended:</strong> {{ snapshot.ended_at|default:"" }}</div>
|
||||||
|
<div><strong>Discovered:</strong> {{ snapshot.discovered_at }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Base</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Record:</strong> {% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% endif %}</div>
|
||||||
|
<div><strong>Kind:</strong> {{ snapshot.base_kind }}</div>
|
||||||
|
<div><strong>Dirname:</strong> {{ snapshot.base_dirname }}</div>
|
||||||
|
<div><strong>Path:</strong> {{ snapshot.base_path }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if stats %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Stats</h2>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Duration:</strong> {{ stats.duration_seconds|default:"" }}{% if stats.duration_seconds is not None %}s{% endif %}</div>
|
||||||
|
<div><strong>Files seen:</strong> {{ stats.rsync.files_total|default:"" }}</div>
|
||||||
|
<div><strong>Files transferred:</strong> {{ stats.rsync.files_transferred|default:"" }}</div>
|
||||||
|
<div><strong>Total file size:</strong> {{ stats.rsync.total_file_size_bytes|filesizeformat }}</div>
|
||||||
|
<div><strong>Estimated link-dest saving:</strong> {{ stats.rsync.link_dest_estimated_savings_bytes|filesizeformat }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Snapshot apparent size:</strong> {{ stats.storage.snapshot.apparent_size_bytes|filesizeformat }}</div>
|
||||||
|
<div><strong>Snapshot allocated size:</strong> {{ stats.storage.snapshot.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div><strong>Hardlinked files:</strong> {{ stats.storage.snapshot.hardlinked_files|default:"" }}</div>
|
||||||
|
<div><strong>Backup root used:</strong> {{ stats.storage.capacity.used_percent|default:"" }}%</div>
|
||||||
|
<div><strong>Backup root available:</strong> {{ stats.storage.capacity.available_bytes|filesizeformat }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Restore Guidance</h2>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Snapshot data path:</strong> {{ restore.source_path }}</div>
|
||||||
|
<div><strong>Example staging destination:</strong> {{ restore.destination_path }}</div>
|
||||||
|
<div class="muted">
|
||||||
|
Restore from the snapshot's <code>data/</code> directory. Start with a dry run, restore to a staging path first,
|
||||||
|
and only then copy data back to a live host or service path.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Inspect the snapshot:</strong></div>
|
||||||
|
<pre>{{ restore.inspect_command }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Dry-run restore to staging:</strong></div>
|
||||||
|
<pre>{{ restore.dry_run_command }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Restore to staging:</strong></div>
|
||||||
|
<pre>{{ restore.local_command }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Dry-run a directory restore:</strong></div>
|
||||||
|
<pre>{{ restore.partial_dry_run_command }}</pre>
|
||||||
|
<div class="muted">Replace <code>{{ restore.example_relative_path }}</code> with the path you want to restore.</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Dry-run a single file restore:</strong></div>
|
||||||
|
<pre>{{ restore.file_dry_run_command }}</pre>
|
||||||
|
<div class="muted">Replace <code>{{ restore.example_file_relative_path }}</code> with the file you want to restore.</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack spaced">
|
||||||
|
<div><strong>Dry-run restore back to the original host:</strong></div>
|
||||||
|
<pre>{{ restore.remote_dry_run_command }}</pre>
|
||||||
|
</div>
|
||||||
|
<p class="muted">
|
||||||
|
Snapshots can contain hardlinks to files shared with earlier snapshots. Treat snapshot directories as read-only:
|
||||||
|
copy data out with rsync instead of editing files in place.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backup Runs</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Run</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Ended</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run in backup_runs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
|
||||||
|
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
|
||||||
|
<td>{{ run.started_at|default:"" }}</td>
|
||||||
|
<td>{{ run.ended_at|default:"" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="muted">No backup runs linked to this snapshot.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Derived Snapshots</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Dirname</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for child in derived_snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ child.kind }}</td>
|
||||||
|
<td>{{ child.status }}</td>
|
||||||
|
<td>{{ child.started_at|default:"" }}</td>
|
||||||
|
<td><a href="{% url 'snapshot_detail' child.id %}">{{ child.dirname }}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="muted">No derived snapshots linked to this snapshot.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Metadata</h2>
|
||||||
|
<pre>{{ metadata_json }}</pre>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if credential %}SSH Key | {{ credential.name }}{% else %}New SSH Key{% endif %} | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if credential %}SSH Key: {{ credential.name }}{% else %}New SSH Key{% endif %}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="SSH key form actions">
|
||||||
|
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>
|
||||||
|
{% if credential and credential.public_key %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Public key</label>
|
||||||
|
<pre><code>{{ credential.public_key }}</code></pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if credential and credential.key_path %}
|
||||||
|
<p class="muted">Private key path: <code>{{ credential.key_path }}</code></p>
|
||||||
|
{% endif %}
|
||||||
|
{% if credential and credential.fingerprint %}
|
||||||
|
<p class="muted">Fingerprint: <code>{{ credential.fingerprint }}</code></p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="field">
|
||||||
|
{{ field.errors }}
|
||||||
|
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">{% if credential %}Save SSH key{% else %}Create SSH key{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if credential %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Delete SSH Key</h2>
|
||||||
|
{% if credential.hosts.exists or credential.global_configs.exists %}
|
||||||
|
<p class="muted">
|
||||||
|
This SSH key is still selected by {{ credential.hosts.count }} host(s) or
|
||||||
|
{{ credential.global_configs.count }} global config(s). Select another key there before deleting it.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Type <strong>{{ credential.name }}</strong> to confirm deletion.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{% url 'delete_ssh_credential' credential.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirm_name">Confirm key name</label>
|
||||||
|
<input id="confirm_name" name="confirm_name" type="text" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="danger" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>Delete SSH key</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Generate SSH Key | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Generate SSH Key</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="SSH key form actions">
|
||||||
|
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Create Key Pair</h2>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="field">
|
||||||
|
{{ field.errors }}
|
||||||
|
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Generate SSH key</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SSH Keys | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>SSH Keys</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="SSH key actions">
|
||||||
|
<a class="button-link" href="{% url 'generate_ssh_credential' %}">Generate SSH key</a>
|
||||||
|
<a class="button-link secondary" href="{% url 'create_ssh_credential' %}">Add existing key</a>
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Credentials</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Public key</th>
|
||||||
|
<th>Fingerprint</th>
|
||||||
|
<th>Known hosts</th>
|
||||||
|
<th>Hosts</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for credential in credentials %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'edit_ssh_credential' credential.id %}">{{ credential.name }}</a></td>
|
||||||
|
<td>{{ credential.key_type }}</td>
|
||||||
|
<td>{% if credential.public_key %}<code>{{ credential.public_key|truncatechars:44 }}</code>{% else %}no{% endif %}</td>
|
||||||
|
<td>{% if credential.fingerprint %}<code>{{ credential.fingerprint }}</code>{% else %}<span class="muted">unknown</span>{% endif %}</td>
|
||||||
|
<td>{{ credential.known_hosts|yesno:"yes,no" }}</td>
|
||||||
|
<td>{{ credential.hosts.count }}</td>
|
||||||
|
<td>{{ credential.updated_at }}</td>
|
||||||
|
<td><a class="button-link secondary" href="{% url 'edit_ssh_credential' credential.id %}">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="8" class="muted">No SSH credentials configured yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
112
src/pobsync_backend/tests/test_admin.py
Normal file
112
src/pobsync_backend/tests/test_admin.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from pobsync_backend.admin import BackupRunAdmin, GlobalConfigAdmin, HostConfigAdmin, SnapshotRecordAdmin
|
||||||
|
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDisplayTests(TestCase):
|
||||||
|
def test_admin_hides_old_global_state_fields_and_labels_host_runtime_state(self) -> None:
|
||||||
|
site = AdminSite()
|
||||||
|
global_admin = GlobalConfigAdmin(GlobalConfig, site)
|
||||||
|
host_admin = HostConfigAdmin(HostConfig, site)
|
||||||
|
|
||||||
|
global_fieldsets = list(global_admin.fieldsets)
|
||||||
|
host_fieldsets = list(host_admin.fieldsets)
|
||||||
|
global_fields = [field for _name, options in global_fieldsets for field in options["fields"]]
|
||||||
|
fieldset_names = [name for name, _options in [*global_fieldsets, *host_fieldsets]]
|
||||||
|
|
||||||
|
self.assertNotIn("pobsync_home", global_fields)
|
||||||
|
self.assertNotIn("data", global_fields)
|
||||||
|
self.assertIn("Runtime state", fieldset_names)
|
||||||
|
self.assertNotIn("Compatibility data", fieldset_names)
|
||||||
|
self.assertNotIn("Legacy JSON", fieldset_names)
|
||||||
|
|
||||||
|
def test_host_admin_links_to_related_snapshots_and_runs(self) -> None:
|
||||||
|
site = AdminSite()
|
||||||
|
admin = HostConfigAdmin(HostConfig, site)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot = SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind="scheduled",
|
||||||
|
dirname="20260519-021500Z__ABCDEFGH",
|
||||||
|
path="/backups/web-01/scheduled/20260519-021500Z__ABCDEFGH",
|
||||||
|
)
|
||||||
|
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||||
|
|
||||||
|
snapshot_link = str(admin.snapshot_count_link(host))
|
||||||
|
run_link = str(admin.backup_run_count_link(host))
|
||||||
|
|
||||||
|
self.assertIn("/admin/pobsync_backend/snapshotrecord/", snapshot_link)
|
||||||
|
self.assertIn(f"host__id__exact={host.pk}", snapshot_link)
|
||||||
|
self.assertIn(">1<", snapshot_link)
|
||||||
|
self.assertIn("/admin/pobsync_backend/backuprun/", run_link)
|
||||||
|
self.assertIn(f"host__id__exact={host.pk}", run_link)
|
||||||
|
self.assertIn(">1<", run_link)
|
||||||
|
|
||||||
|
def test_host_admin_summarizes_schedule_and_latest_run(self) -> None:
|
||||||
|
site = AdminSite()
|
||||||
|
admin = HostConfigAdmin(HostConfig, site)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
self.assertEqual(admin.schedule_state(host), "none")
|
||||||
|
|
||||||
|
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True)
|
||||||
|
host.refresh_from_db()
|
||||||
|
BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
status=BackupRun.Status.SUCCESS,
|
||||||
|
started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(admin.schedule_state(host), "15 2 * * *")
|
||||||
|
self.assertEqual(admin.latest_run_state(host), "success 2026-05-19 02:15")
|
||||||
|
|
||||||
|
def test_snapshot_admin_links_to_base_and_backup_runs(self) -> None:
|
||||||
|
site = AdminSite()
|
||||||
|
admin = SnapshotRecordAdmin(SnapshotRecord, site)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
base = SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind="scheduled",
|
||||||
|
dirname="20260518-021500Z__BASESNAP",
|
||||||
|
path="/backups/web-01/scheduled/20260518-021500Z__BASESNAP",
|
||||||
|
)
|
||||||
|
child = SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind="scheduled",
|
||||||
|
dirname="20260519-021500Z__CHILDSNP",
|
||||||
|
path="/backups/web-01/scheduled/20260519-021500Z__CHILDSNP",
|
||||||
|
base=base,
|
||||||
|
)
|
||||||
|
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=child)
|
||||||
|
|
||||||
|
base_link = str(admin.base_link(child))
|
||||||
|
run_link = str(admin.backup_run_count_link(child))
|
||||||
|
|
||||||
|
self.assertIn(f"/admin/pobsync_backend/snapshotrecord/{base.pk}/change/", base_link)
|
||||||
|
self.assertIn(base.dirname, base_link)
|
||||||
|
self.assertIn("/admin/pobsync_backend/backuprun/", run_link)
|
||||||
|
self.assertIn(f"snapshot__id__exact={child.pk}", run_link)
|
||||||
|
self.assertIn(">1<", run_link)
|
||||||
|
|
||||||
|
def test_backup_run_admin_links_to_snapshot(self) -> None:
|
||||||
|
site = AdminSite()
|
||||||
|
admin = BackupRunAdmin(BackupRun, site)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot = SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind="scheduled",
|
||||||
|
dirname="20260519-021500Z__ABCDEFGH",
|
||||||
|
path="/backups/web-01/scheduled/20260519-021500Z__ABCDEFGH",
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||||
|
|
||||||
|
snapshot_link = str(admin.snapshot_link(run))
|
||||||
|
|
||||||
|
self.assertIn(f"/admin/pobsync_backend/snapshotrecord/{snapshot.pk}/change/", snapshot_link)
|
||||||
|
self.assertIn(snapshot.dirname, snapshot_link)
|
||||||
141
src/pobsync_backend/tests/test_api.py
Normal file
141
src/pobsync_backend/tests/test_api.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone as django_timezone
|
||||||
|
|
||||||
|
from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
user_model = get_user_model()
|
||||||
|
self.staff_user = user_model.objects.create_user(
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
is_staff=True,
|
||||||
|
is_superuser=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_requires_staff_login(self) -> None:
|
||||||
|
response = self.client.get("/api/hosts/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/admin/login/", response["Location"])
|
||||||
|
|
||||||
|
def test_hosts_endpoint_returns_counts_and_schedule(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True)
|
||||||
|
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
||||||
|
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||||
|
|
||||||
|
response = self.client.get("/api/hosts/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertTrue(payload["ok"])
|
||||||
|
self.assertEqual(payload["hosts"][0]["host"], "web-01")
|
||||||
|
self.assertEqual(payload["hosts"][0]["snapshot_count"], 1)
|
||||||
|
self.assertEqual(payload["hosts"][0]["run_count"], 1)
|
||||||
|
self.assertEqual(payload["hosts"][0]["schedule"]["cron_expr"], "15 2 * * *")
|
||||||
|
self.assertTrue(payload["hosts"][0]["schedule"]["prune"])
|
||||||
|
|
||||||
|
def test_snapshots_endpoint_filters_and_returns_base_payload(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
other_host = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||||
|
base = self._snapshot(host, "20260518-021500Z__BASESNAP")
|
||||||
|
self._snapshot(other_host, "20260519-021500Z__OTHERSNP")
|
||||||
|
child = self._snapshot(host, "20260519-021500Z__CHILDSNP", base=base)
|
||||||
|
|
||||||
|
response = self.client.get("/api/snapshots/", {"host": host.host, "kind": "scheduled"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
snapshots = response.json()["snapshots"]
|
||||||
|
self.assertEqual([snapshot["dirname"] for snapshot in snapshots], [child.dirname, base.dirname])
|
||||||
|
self.assertEqual(snapshots[0]["base"]["dirname"], base.dirname)
|
||||||
|
self.assertTrue(snapshots[0]["base"]["resolved"])
|
||||||
|
|
||||||
|
def test_runs_endpoint_filters_by_status_and_limit(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
||||||
|
BackupRun.objects.create(host=host, status=BackupRun.Status.FAILED)
|
||||||
|
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||||
|
|
||||||
|
response = self.client.get("/api/runs/", {"host": host.host, "status": "success", "limit": "1"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
runs = response.json()["runs"]
|
||||||
|
self.assertEqual(len(runs), 1)
|
||||||
|
self.assertEqual(runs[0]["status"], "success")
|
||||||
|
self.assertEqual(runs[0]["snapshot"]["dirname"], snapshot.dirname)
|
||||||
|
|
||||||
|
def test_api_index_lists_endpoints(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
endpoints = response.json()["endpoints"]
|
||||||
|
self.assertEqual(endpoints["status"], "http://testserver/api/status/")
|
||||||
|
self.assertEqual(endpoints["hosts"], "http://testserver/api/hosts/")
|
||||||
|
self.assertEqual(endpoints["snapshots"], "http://testserver/api/snapshots/")
|
||||||
|
self.assertEqual(endpoints["runs"], "http://testserver/api/runs/")
|
||||||
|
|
||||||
|
def test_status_endpoint_returns_counts_and_latest_activity(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
schedule = ScheduleConfig.objects.create(
|
||||||
|
host=host,
|
||||||
|
cron_expr="15 2 * * *",
|
||||||
|
enabled=True,
|
||||||
|
prune=True,
|
||||||
|
last_due_key="202605190215",
|
||||||
|
last_status="success",
|
||||||
|
last_started_at=django_timezone.now(),
|
||||||
|
)
|
||||||
|
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
||||||
|
BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
status=BackupRun.Status.SUCCESS,
|
||||||
|
snapshot=snapshot,
|
||||||
|
started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/api/status/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertTrue(payload["ok"])
|
||||||
|
self.assertEqual(payload["database"]["vendor"], "sqlite")
|
||||||
|
self.assertEqual(payload["counts"]["hosts"], 1)
|
||||||
|
self.assertEqual(payload["counts"]["enabled_hosts"], 1)
|
||||||
|
self.assertEqual(payload["counts"]["enabled_schedules"], 1)
|
||||||
|
self.assertEqual(payload["counts"]["snapshots"], 1)
|
||||||
|
self.assertEqual(payload["counts"]["runs"], 1)
|
||||||
|
self.assertEqual(payload["latest_run"]["host"], host.host)
|
||||||
|
self.assertEqual(payload["latest_run"]["snapshot"]["dirname"], snapshot.dirname)
|
||||||
|
self.assertEqual(payload["latest_schedule"]["host"], host.host)
|
||||||
|
self.assertEqual(payload["latest_schedule"]["last_due_key"], schedule.last_due_key)
|
||||||
|
|
||||||
|
def _snapshot(
|
||||||
|
self,
|
||||||
|
host: HostConfig,
|
||||||
|
dirname: str,
|
||||||
|
*,
|
||||||
|
base: SnapshotRecord | None = None,
|
||||||
|
) -> SnapshotRecord:
|
||||||
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
return SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.SCHEDULED,
|
||||||
|
dirname=dirname,
|
||||||
|
path=f"/backups/{host.host}/scheduled/{dirname}",
|
||||||
|
status="success",
|
||||||
|
started_at=started_at,
|
||||||
|
base=base,
|
||||||
|
)
|
||||||
240
src/pobsync_backend/tests/test_backup_worker.py
Normal file
240
src/pobsync_backend/tests/test_backup_worker.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from pobsync.util import write_yaml_atomic
|
||||||
|
from pobsync_backend.backup_runner import queue_backup_run, reconcile_running_runs
|
||||||
|
from pobsync_backend.management.commands.run_pobsync_worker import Command
|
||||||
|
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
|
class BackupWorkerTests(TestCase):
|
||||||
|
def test_queue_backup_run_records_requested_options(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
run = queue_backup_run(
|
||||||
|
host=host,
|
||||||
|
dry_run=True,
|
||||||
|
prune=True,
|
||||||
|
prune_max_delete=3,
|
||||||
|
prune_protect_bases=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.QUEUED)
|
||||||
|
self.assertEqual(run.run_type, BackupRun.RunType.MANUAL)
|
||||||
|
self.assertEqual(
|
||||||
|
run.result["requested"],
|
||||||
|
{
|
||||||
|
"dry_run": True,
|
||||||
|
"verbose_output": True,
|
||||||
|
"prune": True,
|
||||||
|
"prune_max_delete": 3,
|
||||||
|
"prune_protect_bases": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_queue_backup_run_can_request_verbose_output(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
run = queue_backup_run(host=host, verbose_output=True)
|
||||||
|
|
||||||
|
self.assertTrue(run.result["requested"]["verbose_output"])
|
||||||
|
|
||||||
|
def test_worker_executes_next_queued_run(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
|
||||||
|
meta_dir = snapshot_dir / "meta"
|
||||||
|
meta_dir.mkdir(parents=True)
|
||||||
|
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
|
||||||
|
run = queue_backup_run(host=host, verbose_output=True)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
|
def fake_run_scheduled(**kwargs):
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertIn("execution", run.result)
|
||||||
|
self.assertIn("worker_pid", run.result["execution"])
|
||||||
|
self.assertIn("worker_host", run.result["execution"])
|
||||||
|
self.assertIn("heartbeat_at", run.result["execution"])
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": False,
|
||||||
|
"host": host.host,
|
||||||
|
"snapshot": str(snapshot_dir),
|
||||||
|
"base": None,
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scheduled.side_effect = fake_run_scheduled
|
||||||
|
count = Command()._run_once(prefix=Path(tmp) / "home")
|
||||||
|
run_scheduled.assert_called_once()
|
||||||
|
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
self.assertEqual(run_scheduled.call_args.kwargs["run_id"], run.id)
|
||||||
|
self.assertTrue(run_scheduled.call_args.kwargs["verbose_output"])
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.SUCCESS)
|
||||||
|
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
||||||
|
self.assertEqual(run.snapshot, SnapshotRecord.objects.get())
|
||||||
|
|
||||||
|
def test_worker_refreshes_heartbeat_while_run_is_active(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = queue_backup_run(host=host)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
|
def fake_run_scheduled(**kwargs):
|
||||||
|
run.refresh_from_db()
|
||||||
|
old_heartbeat = timezone.now() - timedelta(seconds=120)
|
||||||
|
run.result["execution"]["heartbeat_at"] = old_heartbeat.isoformat()
|
||||||
|
run.save(update_fields=["result"])
|
||||||
|
|
||||||
|
self.assertFalse(kwargs["cancel_check"]())
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertGreater(
|
||||||
|
timezone.datetime.fromisoformat(run.result["execution"]["heartbeat_at"]),
|
||||||
|
old_heartbeat,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": False,
|
||||||
|
"host": host.host,
|
||||||
|
"snapshot": "",
|
||||||
|
"base": None,
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scheduled.side_effect = fake_run_scheduled
|
||||||
|
Command()._run_once(prefix=Path(tmp) / "home")
|
||||||
|
|
||||||
|
def test_worker_reconciles_stale_real_run_after_heartbeat_timeout(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = queue_backup_run(host=host)
|
||||||
|
run.status = BackupRun.Status.RUNNING
|
||||||
|
run.started_at = timezone.now() - timedelta(seconds=120)
|
||||||
|
run.result["execution"] = {
|
||||||
|
"worker_pid": 123,
|
||||||
|
"worker_host": "backup",
|
||||||
|
"heartbeat_at": (timezone.now() - timedelta(seconds=90)).isoformat(),
|
||||||
|
}
|
||||||
|
run.save(update_fields=["status", "started_at", "result"])
|
||||||
|
|
||||||
|
reconciled = reconcile_running_runs(stale_worker_seconds=30)
|
||||||
|
|
||||||
|
self.assertEqual(reconciled, 1)
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.FAILED)
|
||||||
|
self.assertEqual(run.result["failure"]["category"], "worker")
|
||||||
|
self.assertIn("heartbeat stopped", run.result["failure"]["message"])
|
||||||
|
|
||||||
|
def test_worker_records_dry_run_log_path_while_running(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = queue_backup_run(host=host, dry_run=True)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
|
def fake_run_scheduled(**kwargs):
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
run.result["execution"]["log"],
|
||||||
|
f"/tmp/pobsync-dryrun/{host.host}/run-{run.id}/rsync.log",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": True,
|
||||||
|
"host": host.host,
|
||||||
|
"base": None,
|
||||||
|
"log": run.result["execution"]["log"],
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scheduled.side_effect = fake_run_scheduled
|
||||||
|
count = Command()._run_once(prefix=Path(tmp) / "home")
|
||||||
|
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.SUCCESS)
|
||||||
|
self.assertEqual(run.result["log"], f"/tmp/pobsync-dryrun/{host.host}/run-{run.id}/rsync.log")
|
||||||
|
|
||||||
|
def test_worker_preserves_cancelled_status_from_running_run(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = queue_backup_run(host=host, dry_run=True)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
|
def fake_run_scheduled(**kwargs):
|
||||||
|
BackupRun.objects.filter(id=run.id).update(status=BackupRun.Status.CANCELLED)
|
||||||
|
self.assertTrue(kwargs["cancel_check"]())
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"dry_run": True,
|
||||||
|
"cancelled": True,
|
||||||
|
"host": host.host,
|
||||||
|
"base": None,
|
||||||
|
"log": f"/tmp/pobsync-dryrun/{host.host}/run-{run.id}/rsync.log",
|
||||||
|
"rsync": {"exit_code": 130},
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scheduled.side_effect = fake_run_scheduled
|
||||||
|
count = Command()._run_once(prefix=Path(tmp) / "home")
|
||||||
|
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.CANCELLED)
|
||||||
|
self.assertEqual(run.rsync_exit_code, 130)
|
||||||
|
|
||||||
|
def test_worker_returns_zero_without_queued_runs(self) -> None:
|
||||||
|
count = Command()._run_once(prefix=Path("/opt/pobsync"))
|
||||||
|
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
||||||
|
def test_worker_reconciles_running_dry_run_with_terminal_broken_pipe_log(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = queue_backup_run(host=host, dry_run=True)
|
||||||
|
log_path = Path("/tmp/pobsync-dryrun/web-01/run-test-broken-pipe.log")
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text(
|
||||||
|
"rsync error: unexplained error (code 255) at rsync.c(716) [generator=3.4.1]\n"
|
||||||
|
"rsync: [generator] write error: Broken pipe (32)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
run.status = BackupRun.Status.RUNNING
|
||||||
|
run.started_at = timezone.now()
|
||||||
|
run.result["execution"] = {"log": str(log_path)}
|
||||||
|
run.save(update_fields=["status", "started_at", "result"])
|
||||||
|
|
||||||
|
reconciled = reconcile_running_runs()
|
||||||
|
|
||||||
|
self.assertEqual(reconciled, 1)
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.FAILED)
|
||||||
|
self.assertEqual(run.rsync_exit_code, 255)
|
||||||
|
self.assertEqual(run.result["failure"]["category"], "transport")
|
||||||
|
self.assertIn("Broken pipe", "\n".join(run.result["rsync"]["log_tail"]))
|
||||||
|
|
||||||
|
def test_worker_reconciles_stale_running_dry_run_after_timeout(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = queue_backup_run(host=host, dry_run=True)
|
||||||
|
run.status = BackupRun.Status.RUNNING
|
||||||
|
run.started_at = timezone.now() - timedelta(seconds=1300)
|
||||||
|
run.result["timeout_seconds"] = 900
|
||||||
|
run.save(update_fields=["status", "started_at", "result"])
|
||||||
|
|
||||||
|
reconciled = reconcile_running_runs(grace_seconds=300)
|
||||||
|
|
||||||
|
self.assertEqual(reconciled, 1)
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.FAILED)
|
||||||
|
self.assertEqual(run.rsync_exit_code, 124)
|
||||||
|
self.assertEqual(run.result["failure"]["category"], "timeout")
|
||||||
@@ -1,23 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from pobsync.config.load import load_global_config, load_host_config
|
from pobsync_backend.config_repository import ConfigRepositoryError, global_config_data, host_config_data
|
||||||
from pobsync_backend.config_repository import export_runtime_configs
|
|
||||||
from pobsync_backend.models import GlobalConfig, HostConfig
|
from pobsync_backend.models import GlobalConfig, HostConfig
|
||||||
|
|
||||||
|
|
||||||
class ConfigRepositoryTests(TestCase):
|
class ConfigRepositoryTests(TestCase):
|
||||||
def test_exports_database_configs_to_engine_yaml(self) -> None:
|
def test_builds_runtime_config_from_database_fields(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
prefix = Path(tmp)
|
|
||||||
GlobalConfig.objects.create(
|
GlobalConfig.objects.create(
|
||||||
name="default",
|
name="default",
|
||||||
backup_root="/backups",
|
backup_root="/backups",
|
||||||
pobsync_home=str(prefix),
|
|
||||||
ssh_user="backup",
|
ssh_user="backup",
|
||||||
ssh_port=2222,
|
ssh_port=2222,
|
||||||
rsync_args=["--archive"],
|
rsync_args=["--archive"],
|
||||||
@@ -26,13 +19,6 @@ class ConfigRepositoryTests(TestCase):
|
|||||||
retention_weekly=4,
|
retention_weekly=4,
|
||||||
retention_monthly=3,
|
retention_monthly=3,
|
||||||
retention_yearly=1,
|
retention_yearly=1,
|
||||||
data={
|
|
||||||
"backup_root": "/ignored",
|
|
||||||
"pobsync_home": "/ignored",
|
|
||||||
"ssh": {"user": "ignored", "port": 22, "options": []},
|
|
||||||
"unknown": "must-not-leak",
|
|
||||||
"retention_defaults": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
HostConfig.objects.create(
|
HostConfig.objects.create(
|
||||||
host="web-01",
|
host="web-01",
|
||||||
@@ -53,13 +39,10 @@ class ConfigRepositoryTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
written = export_runtime_configs(prefix=prefix, host="web-01")
|
global_cfg = global_config_data()
|
||||||
|
host_cfg = host_config_data("web-01")
|
||||||
|
|
||||||
self.assertEqual(len(written), 2)
|
|
||||||
global_cfg = load_global_config(prefix / "config" / "global.yaml")
|
|
||||||
host_cfg = load_host_config(prefix / "config" / "hosts" / "web-01.yaml")
|
|
||||||
self.assertEqual(global_cfg["backup_root"], "/backups")
|
self.assertEqual(global_cfg["backup_root"], "/backups")
|
||||||
self.assertEqual(global_cfg["pobsync_home"], str(prefix))
|
|
||||||
self.assertEqual(global_cfg["ssh"]["user"], "backup")
|
self.assertEqual(global_cfg["ssh"]["user"], "backup")
|
||||||
self.assertEqual(global_cfg["ssh"]["port"], 2222)
|
self.assertEqual(global_cfg["ssh"]["port"], 2222)
|
||||||
self.assertEqual(global_cfg["retention_defaults"]["daily"], 7)
|
self.assertEqual(global_cfg["retention_defaults"]["daily"], 7)
|
||||||
@@ -69,3 +52,12 @@ class ConfigRepositoryTests(TestCase):
|
|||||||
self.assertEqual(host_cfg["excludes_add"], ["/tmp/***"])
|
self.assertEqual(host_cfg["excludes_add"], ["/tmp/***"])
|
||||||
self.assertNotIn("unknown", global_cfg)
|
self.assertNotIn("unknown", global_cfg)
|
||||||
self.assertNotIn("unknown", host_cfg)
|
self.assertNotIn("unknown", host_cfg)
|
||||||
|
|
||||||
|
def test_missing_config_errors_use_operator_labels(self) -> None:
|
||||||
|
with self.assertRaisesMessage(ConfigRepositoryError, "Missing global config 'default'"):
|
||||||
|
global_config_data()
|
||||||
|
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(ConfigRepositoryError, "Missing enabled host 'web-01'"):
|
||||||
|
host_config_data("web-01")
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class ConfigureCommandsTests(TestCase):
|
|||||||
call_command(
|
call_command(
|
||||||
"configure_pobsync_global",
|
"configure_pobsync_global",
|
||||||
backup_root="/backups",
|
backup_root="/backups",
|
||||||
pobsync_home="/opt/pobsync",
|
|
||||||
retention="daily=3,weekly=2,monthly=1,yearly=0",
|
retention="daily=3,weekly=2,monthly=1,yearly=0",
|
||||||
stdout=out,
|
stdout=out,
|
||||||
)
|
)
|
||||||
@@ -24,7 +23,7 @@ class ConfigureCommandsTests(TestCase):
|
|||||||
config = GlobalConfig.objects.get(name="default")
|
config = GlobalConfig.objects.get(name="default")
|
||||||
self.assertEqual(config.backup_root, "/backups")
|
self.assertEqual(config.backup_root, "/backups")
|
||||||
self.assertEqual(config.retention_daily, 3)
|
self.assertEqual(config.retention_daily, 3)
|
||||||
self.assertIn("Created GlobalConfig", out.getvalue())
|
self.assertIn("Created global config", out.getvalue())
|
||||||
|
|
||||||
def test_configure_host_uses_global_retention_defaults(self) -> None:
|
def test_configure_host_uses_global_retention_defaults(self) -> None:
|
||||||
GlobalConfig.objects.create(
|
GlobalConfig.objects.create(
|
||||||
@@ -62,7 +61,7 @@ class ConfigureCommandsTests(TestCase):
|
|||||||
call_command(
|
call_command(
|
||||||
"configure_pobsync_schedule",
|
"configure_pobsync_schedule",
|
||||||
host.host,
|
host.host,
|
||||||
cron="15 2 * * *",
|
schedule_expression="15 2 * * *",
|
||||||
prune=True,
|
prune=True,
|
||||||
stdout=out,
|
stdout=out,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ from pobsync.cli import main
|
|||||||
|
|
||||||
|
|
||||||
class ConsoleEntrypointTests(SimpleTestCase):
|
class ConsoleEntrypointTests(SimpleTestCase):
|
||||||
|
def test_version_prints_package_version(self) -> None:
|
||||||
|
stdout = StringIO()
|
||||||
|
with patch("sys.stdout", stdout):
|
||||||
|
exit_code = main(["--version"])
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 0)
|
||||||
|
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.0.0")
|
||||||
|
|
||||||
def test_maps_backup_alias_to_django_command(self) -> None:
|
def test_maps_backup_alias_to_django_command(self) -> None:
|
||||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||||
exit_code = main(["backup", "web-01", "--dry-run"])
|
exit_code = main(["backup", "web-01", "--dry-run"])
|
||||||
@@ -31,18 +39,25 @@ class ConsoleEntrypointTests(SimpleTestCase):
|
|||||||
self.assertEqual(exit_code, 0)
|
self.assertEqual(exit_code, 0)
|
||||||
execute.assert_called_once_with(["pobsync", "check"])
|
execute.assert_called_once_with(["pobsync", "check"])
|
||||||
|
|
||||||
def test_maps_schedule_alias_to_django_command(self) -> None:
|
|
||||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
|
||||||
exit_code = main(["schedule", "web-01", "--cron", "15 2 * * *"])
|
|
||||||
|
|
||||||
self.assertEqual(exit_code, 0)
|
|
||||||
execute.assert_called_once_with(
|
|
||||||
["pobsync", "configure_pobsync_schedule", "web-01", "--cron", "15 2 * * *"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_maps_discover_snapshots_alias_to_django_command(self) -> None:
|
def test_maps_discover_snapshots_alias_to_django_command(self) -> None:
|
||||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||||
exit_code = main(["discover-snapshots", "--host", "web-01"])
|
exit_code = main(["discover-snapshots", "--host", "web-01"])
|
||||||
|
|
||||||
self.assertEqual(exit_code, 0)
|
self.assertEqual(exit_code, 0)
|
||||||
execute.assert_called_once_with(["pobsync", "discover_pobsync_snapshots", "--host", "web-01"])
|
execute.assert_called_once_with(["pobsync", "discover_pobsync_snapshots", "--host", "web-01"])
|
||||||
|
|
||||||
|
def test_maps_worker_alias_to_django_command(self) -> None:
|
||||||
|
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||||
|
exit_code = main(["worker", "--once"])
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 0)
|
||||||
|
execute.assert_called_once_with(["pobsync", "run_pobsync_worker", "--once"])
|
||||||
|
|
||||||
|
def test_configuration_aliases_are_not_public_commands(self) -> None:
|
||||||
|
stderr = StringIO()
|
||||||
|
with patch("sys.stderr", stderr):
|
||||||
|
exit_code = main(["schedule", "web-01", "--cron", "15 2 * * *"])
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 2)
|
||||||
|
self.assertIn("Unknown pobsync command", stderr.getvalue())
|
||||||
|
self.assertIn("pobsync django <management-command>", stderr.getvalue())
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from django.test import TestCase
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
from pobsync_backend.config_source import DjangoConfigSource
|
from pobsync_backend.config_source import DjangoConfigSource
|
||||||
from pobsync_backend.models import GlobalConfig, HostConfig
|
from pobsync_backend.models import GlobalConfig, HostConfig, SshCredential
|
||||||
|
|
||||||
|
|
||||||
class DjangoConfigSourceTests(TestCase):
|
class DjangoConfigSourceTests(TestCase):
|
||||||
@@ -11,7 +15,6 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
GlobalConfig.objects.create(
|
GlobalConfig.objects.create(
|
||||||
name="default",
|
name="default",
|
||||||
backup_root="/backups",
|
backup_root="/backups",
|
||||||
pobsync_home="/opt/pobsync",
|
|
||||||
rsync_args=["--archive"],
|
rsync_args=["--archive"],
|
||||||
rsync_extra_args=["--numeric-ids"],
|
rsync_extra_args=["--numeric-ids"],
|
||||||
excludes_default=["/proc/***"],
|
excludes_default=["/proc/***"],
|
||||||
@@ -19,21 +22,6 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
retention_weekly=4,
|
retention_weekly=4,
|
||||||
retention_monthly=3,
|
retention_monthly=3,
|
||||||
retention_yearly=1,
|
retention_yearly=1,
|
||||||
data={
|
|
||||||
"backup_root": "/ignored",
|
|
||||||
"pobsync_home": "/ignored",
|
|
||||||
"ssh": {"user": "root", "port": 22, "options": []},
|
|
||||||
"rsync": {
|
|
||||||
"binary": "rsync",
|
|
||||||
"args": ["--archive"],
|
|
||||||
"timeout_seconds": 0,
|
|
||||||
"bwlimit_kbps": 0,
|
|
||||||
"extra_args": ["--numeric-ids"],
|
|
||||||
},
|
|
||||||
"defaults": {"source_root": "/", "destination_subdir": ""},
|
|
||||||
"excludes_default": ["/proc/***"],
|
|
||||||
"retention_defaults": {"daily": 7, "weekly": 4, "monthly": 3, "yearly": 1},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
HostConfig.objects.create(
|
HostConfig.objects.create(
|
||||||
host="web-01",
|
host="web-01",
|
||||||
@@ -58,3 +46,95 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
self.assertEqual(cfg["address"], "web-01.example.test")
|
self.assertEqual(cfg["address"], "web-01.example.test")
|
||||||
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
|
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
|
||||||
self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"])
|
self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"])
|
||||||
|
|
||||||
|
def test_materializes_global_ssh_credential_for_runtime_config(self) -> None:
|
||||||
|
credential = SshCredential.objects.create(
|
||||||
|
name="backup-key",
|
||||||
|
private_key="PRIVATE KEY",
|
||||||
|
known_hosts="web-01.example.test ssh-ed25519 AAAATEST",
|
||||||
|
)
|
||||||
|
GlobalConfig.objects.create(
|
||||||
|
name="default",
|
||||||
|
backup_root="/backups",
|
||||||
|
default_ssh_credential=credential,
|
||||||
|
ssh_options=["-oBatchMode=yes"],
|
||||||
|
)
|
||||||
|
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||||
|
cfg = DjangoConfigSource().effective_config_for_host("web-01")
|
||||||
|
|
||||||
|
identity_file = Path(tmp) / "home" / "state" / "ssh-credentials" / str(credential.pk) / "identity"
|
||||||
|
known_hosts = identity_file.parent / "known_hosts"
|
||||||
|
self.assertEqual(identity_file.read_text(encoding="utf-8"), "PRIVATE KEY\n")
|
||||||
|
self.assertEqual(known_hosts.read_text(encoding="utf-8"), "web-01.example.test ssh-ed25519 AAAATEST\n")
|
||||||
|
self.assertEqual(stat.S_IMODE(identity_file.stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
self.assertIn("-oBatchMode=yes", cfg["ssh"]["options"])
|
||||||
|
self.assertIn(f"-oIdentityFile={identity_file}", cfg["ssh"]["options"])
|
||||||
|
self.assertIn(f"-oUserKnownHostsFile={known_hosts}", cfg["ssh"]["options"])
|
||||||
|
self.assertNotIn("-oStrictHostKeyChecking=accept-new", cfg["ssh"]["options"])
|
||||||
|
self.assertEqual(cfg["ssh_credential"]["storage"], "database")
|
||||||
|
|
||||||
|
def test_host_ssh_credential_overrides_global_credential(self) -> None:
|
||||||
|
global_credential = SshCredential.objects.create(name="global-key", private_key="GLOBAL")
|
||||||
|
host_credential = SshCredential.objects.create(name="host-key", private_key="HOST")
|
||||||
|
GlobalConfig.objects.create(
|
||||||
|
name="default",
|
||||||
|
backup_root="/backups",
|
||||||
|
default_ssh_credential=global_credential,
|
||||||
|
)
|
||||||
|
HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
ssh_credential=host_credential,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||||
|
cfg = DjangoConfigSource().effective_config_for_host("web-01")
|
||||||
|
|
||||||
|
host_identity_file = Path(tmp) / "home" / "state" / "ssh-credentials" / str(host_credential.pk) / "identity"
|
||||||
|
global_identity_file = Path(tmp) / "home" / "state" / "ssh-credentials" / str(global_credential.pk) / "identity"
|
||||||
|
self.assertEqual(host_identity_file.read_text(encoding="utf-8"), "HOST\n")
|
||||||
|
self.assertFalse(global_identity_file.exists())
|
||||||
|
|
||||||
|
self.assertIn(f"-oIdentityFile={host_identity_file}", cfg["ssh"]["options"])
|
||||||
|
|
||||||
|
def test_filesystem_ssh_credential_uses_existing_key_path(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
identity_file = Path(tmp) / "identity"
|
||||||
|
identity_file.write_text("PRIVATE KEY\n", encoding="utf-8")
|
||||||
|
identity_file.chmod(0o600)
|
||||||
|
credential = SshCredential.objects.create(name="backup-key", key_path=str(identity_file), public_key="PUBLIC")
|
||||||
|
GlobalConfig.objects.create(
|
||||||
|
name="default",
|
||||||
|
backup_root="/backups",
|
||||||
|
default_ssh_credential=credential,
|
||||||
|
)
|
||||||
|
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
cfg = DjangoConfigSource().effective_config_for_host("web-01")
|
||||||
|
|
||||||
|
self.assertIn(f"-oIdentityFile={identity_file}", cfg["ssh"]["options"])
|
||||||
|
self.assertEqual(cfg["ssh_credential"]["storage"], "filesystem")
|
||||||
|
|
||||||
|
def test_missing_known_hosts_uses_service_accept_new_file(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
identity_file = Path(tmp) / "identity"
|
||||||
|
identity_file.write_text("PRIVATE KEY\n", encoding="utf-8")
|
||||||
|
identity_file.chmod(0o600)
|
||||||
|
credential = SshCredential.objects.create(name="backup-key", key_path=str(identity_file), public_key="PUBLIC")
|
||||||
|
GlobalConfig.objects.create(
|
||||||
|
name="default",
|
||||||
|
backup_root="/backups",
|
||||||
|
default_ssh_credential=credential,
|
||||||
|
)
|
||||||
|
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
with override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||||
|
cfg = DjangoConfigSource().effective_config_for_host("web-01")
|
||||||
|
service_known_hosts = Path(tmp) / "home" / "state" / "known_hosts"
|
||||||
|
|
||||||
|
self.assertTrue(service_known_hosts.exists())
|
||||||
|
self.assertIn(f"-oUserKnownHostsFile={service_known_hosts}", cfg["ssh"]["options"])
|
||||||
|
self.assertIn("-oStrictHostKeyChecking=accept-new", cfg["ssh"]["options"])
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from tempfile import TemporaryDirectory
|
|||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
from pobsync.commands.retention_plan import run_retention_plan
|
from pobsync.commands.retention_plan import run_retention_plan
|
||||||
|
from pobsync.errors import ConfigError
|
||||||
from pobsync.util import write_yaml_atomic
|
from pobsync.util import write_yaml_atomic
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +25,15 @@ class FakeConfigSource:
|
|||||||
|
|
||||||
|
|
||||||
class RetentionConfigSourceTests(SimpleTestCase):
|
class RetentionConfigSourceTests(SimpleTestCase):
|
||||||
|
def test_retention_plan_requires_explicit_config_source(self) -> None:
|
||||||
|
with self.assertRaisesMessage(ConfigError, "A Django config source is required."):
|
||||||
|
run_retention_plan(
|
||||||
|
prefix=Path("/missing-prefix"),
|
||||||
|
host="web-01",
|
||||||
|
kind="scheduled",
|
||||||
|
protect_bases=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_retention_plan_uses_injected_config_source(self) -> None:
|
def test_retention_plan_uses_injected_config_source(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp) / "backups"
|
root = Path(tmp) / "backups"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.core.management import call_command
|
|||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from pobsync.errors import ConfigError
|
||||||
from pobsync.util import write_yaml_atomic
|
from pobsync.util import write_yaml_atomic
|
||||||
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled:
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
run_scheduled.return_value = {
|
run_scheduled.return_value = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
@@ -43,12 +44,109 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
call_command("run_pobsync_backup", host.host, prefix=str(Path(tmp) / "home"), stdout=StringIO())
|
call_command("run_pobsync_backup", host.host, prefix=str(Path(tmp) / "home"), stdout=StringIO())
|
||||||
|
|
||||||
self.assertEqual(BackupRun.objects.count(), 1)
|
self.assertEqual(BackupRun.objects.count(), 1)
|
||||||
|
run = BackupRun.objects.get()
|
||||||
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
||||||
record = SnapshotRecord.objects.get()
|
record = SnapshotRecord.objects.get()
|
||||||
|
self.assertEqual(run.snapshot, record)
|
||||||
self.assertEqual(record.host, host)
|
self.assertEqual(record.host, host)
|
||||||
self.assertEqual(record.kind, "scheduled")
|
self.assertEqual(record.kind, "scheduled")
|
||||||
self.assertEqual(record.status, "success")
|
self.assertEqual(record.status, "success")
|
||||||
|
|
||||||
|
def test_prune_uses_sql_retention_after_snapshot_record_is_created(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
|
||||||
|
meta_dir = snapshot_dir / "meta"
|
||||||
|
meta_dir.mkdir(parents=True)
|
||||||
|
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled,
|
||||||
|
patch(
|
||||||
|
"pobsync_backend.backup_runner.run_sql_retention_apply"
|
||||||
|
) as retention_apply,
|
||||||
|
):
|
||||||
|
run_scheduled.return_value = {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": False,
|
||||||
|
"host": host.host,
|
||||||
|
"snapshot": str(snapshot_dir),
|
||||||
|
"base": None,
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
retention_apply.return_value = {"ok": True, "source": "sql", "deleted": []}
|
||||||
|
call_command(
|
||||||
|
"run_pobsync_backup",
|
||||||
|
host.host,
|
||||||
|
prefix=str(Path(tmp) / "home"),
|
||||||
|
prune=True,
|
||||||
|
prune_max_delete=3,
|
||||||
|
prune_protect_bases=True,
|
||||||
|
stdout=StringIO(),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_scheduled.assert_called_once()
|
||||||
|
self.assertFalse(run_scheduled.call_args.kwargs["prune"])
|
||||||
|
retention_apply.assert_called_once_with(
|
||||||
|
prefix=Path(tmp) / "home",
|
||||||
|
host=host.host,
|
||||||
|
kind="scheduled",
|
||||||
|
protect_bases=True,
|
||||||
|
yes=True,
|
||||||
|
max_delete=3,
|
||||||
|
action=BackupRun.RunType.SCHEDULED,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.get()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.SUCCESS)
|
||||||
|
self.assertEqual(run.result["prune"], {"ok": True, "source": "sql", "deleted": []})
|
||||||
|
|
||||||
|
def test_prune_failure_marks_backup_run_as_warning(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
|
||||||
|
meta_dir = snapshot_dir / "meta"
|
||||||
|
meta_dir.mkdir(parents=True)
|
||||||
|
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled,
|
||||||
|
patch(
|
||||||
|
"pobsync_backend.backup_runner.run_sql_retention_apply"
|
||||||
|
) as retention_apply,
|
||||||
|
):
|
||||||
|
run_scheduled.return_value = {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": False,
|
||||||
|
"host": host.host,
|
||||||
|
"snapshot": str(snapshot_dir),
|
||||||
|
"base": None,
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
retention_apply.side_effect = ConfigError("Deletion blocked by --max-delete=0")
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
call_command(
|
||||||
|
"run_pobsync_backup",
|
||||||
|
host.host,
|
||||||
|
prefix=str(Path(tmp) / "home"),
|
||||||
|
prune=True,
|
||||||
|
prune_max_delete=0,
|
||||||
|
stdout=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
run = BackupRun.objects.get()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.WARNING)
|
||||||
|
self.assertIsNotNone(run.snapshot)
|
||||||
|
self.assertIn("completed with warnings", output.getvalue())
|
||||||
|
self.assertEqual(run.result["prune"]["ok"], False)
|
||||||
|
self.assertEqual(run.result["prune"]["type"], "ConfigError")
|
||||||
|
self.assertEqual(run.result["prune"]["error"], "Deletion blocked by --max-delete=0")
|
||||||
|
|
||||||
def test_failed_backup_upserts_incomplete_snapshot_record(self) -> None:
|
def test_failed_backup_upserts_incomplete_snapshot_record(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
backup_root = Path(tmp) / "backups"
|
backup_root = Path(tmp) / "backups"
|
||||||
@@ -59,7 +157,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
meta_dir.mkdir(parents=True)
|
meta_dir.mkdir(parents=True)
|
||||||
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "failed", "started_at": "2026-05-19T02:15:00Z"})
|
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "failed", "started_at": "2026-05-19T02:15:00Z"})
|
||||||
|
|
||||||
with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled:
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
run_scheduled.return_value = {
|
run_scheduled.return_value = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
@@ -74,6 +172,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
run = BackupRun.objects.get()
|
run = BackupRun.objects.get()
|
||||||
self.assertEqual(run.status, BackupRun.Status.FAILED)
|
self.assertEqual(run.status, BackupRun.Status.FAILED)
|
||||||
record = SnapshotRecord.objects.get()
|
record = SnapshotRecord.objects.get()
|
||||||
|
self.assertEqual(run.snapshot, record)
|
||||||
self.assertEqual(record.kind, "incomplete")
|
self.assertEqual(record.kind, "incomplete")
|
||||||
self.assertEqual(record.status, "failed")
|
self.assertEqual(record.status, "failed")
|
||||||
|
|
||||||
@@ -82,7 +181,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled:
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
run_scheduled.return_value = {
|
run_scheduled.return_value = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
@@ -99,4 +198,30 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(BackupRun.objects.count(), 1)
|
self.assertEqual(BackupRun.objects.count(), 1)
|
||||||
|
self.assertIsNone(BackupRun.objects.get().snapshot)
|
||||||
self.assertEqual(SnapshotRecord.objects.count(), 0)
|
self.assertEqual(SnapshotRecord.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_manual_flag_records_manual_run_type(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||||
|
run_scheduled.return_value = {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": True,
|
||||||
|
"host": host.host,
|
||||||
|
"base": None,
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
call_command(
|
||||||
|
"run_pobsync_backup",
|
||||||
|
host.host,
|
||||||
|
prefix=str(Path(tmp) / "home"),
|
||||||
|
dry_run=True,
|
||||||
|
manual=True,
|
||||||
|
stdout=StringIO(),
|
||||||
|
)
|
||||||
|
|
||||||
|
run = BackupRun.objects.get()
|
||||||
|
self.assertEqual(run.run_type, BackupRun.RunType.MANUAL)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from unittest.mock import patch
|
|||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
from pobsync.commands.run_scheduled import run_scheduled
|
from pobsync.commands.run_scheduled import run_scheduled
|
||||||
|
from pobsync.errors import ConfigError
|
||||||
from pobsync.rsync import RsyncResult
|
from pobsync.rsync import RsyncResult
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ class FakeConfigSource:
|
|||||||
|
|
||||||
|
|
||||||
class RunScheduledConfigSourceTests(SimpleTestCase):
|
class RunScheduledConfigSourceTests(SimpleTestCase):
|
||||||
|
def test_requires_explicit_config_source(self) -> None:
|
||||||
|
with self.assertRaisesMessage(ConfigError, "A Django config source is required."):
|
||||||
|
run_scheduled(prefix=Path("/missing-prefix"), host="web-01", dry_run=True)
|
||||||
|
|
||||||
def test_dry_run_uses_injected_config_source(self) -> None:
|
def test_dry_run_uses_injected_config_source(self) -> None:
|
||||||
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
|
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
|
||||||
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
|
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
|
||||||
@@ -49,6 +54,228 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
|
|||||||
self.assertEqual(result["host"], "web-01")
|
self.assertEqual(result["host"], "web-01")
|
||||||
run_rsync.assert_called_once()
|
run_rsync.assert_called_once()
|
||||||
|
|
||||||
|
def test_failed_dry_run_includes_log_tail(self) -> None:
|
||||||
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text("Permission denied (publickey).\nrsync error\n", encoding="utf-8")
|
||||||
|
return RsyncResult(exit_code=12, command=command)
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=Path("/missing-prefix"),
|
||||||
|
host="web-01",
|
||||||
|
dry_run=True,
|
||||||
|
config_source=FakeConfigSource(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(result["rsync"]["exit_code"], 12)
|
||||||
|
self.assertEqual(result["rsync"]["log_tail"], ["Permission denied (publickey).", "rsync error"])
|
||||||
|
self.assertEqual(result["failure"]["category"], "permissions")
|
||||||
|
|
||||||
|
def test_failed_dry_run_classifies_broken_pipe(self) -> None:
|
||||||
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text(
|
||||||
|
"rsync error: unexplained error (code 255) at rsync.c(716) [generator=3.4.1]\n"
|
||||||
|
"rsync: [generator] write error: Broken pipe (32)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return RsyncResult(exit_code=255, command=command)
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=Path("/missing-prefix"),
|
||||||
|
host="web-01",
|
||||||
|
dry_run=True,
|
||||||
|
config_source=FakeConfigSource(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(result["rsync"]["exit_code"], 255)
|
||||||
|
self.assertEqual(result["failure"]["category"], "transport")
|
||||||
|
self.assertIn("broken pipe", result["failure"]["hint"].lower())
|
||||||
|
|
||||||
|
def test_dry_run_clears_previous_log_before_running(self) -> None:
|
||||||
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
|
self.assertFalse(log_path.exists())
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text("current run only\n", encoding="utf-8")
|
||||||
|
return RsyncResult(exit_code=0, command=command)
|
||||||
|
|
||||||
|
old_log = Path("/tmp/pobsync-dryrun/web-01/adhoc/rsync.log")
|
||||||
|
old_log.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
old_log.write_text("old failure\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=Path("/missing-prefix"),
|
||||||
|
host="web-01",
|
||||||
|
dry_run=True,
|
||||||
|
config_source=FakeConfigSource(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual(result["rsync"]["log_tail"], ["current run only"])
|
||||||
|
|
||||||
|
def test_dry_run_uses_run_specific_log_path_and_default_timeout(self) -> None:
|
||||||
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
|
self.assertEqual(log_path, Path("/tmp/pobsync-dryrun/web-01/run-42/rsync.log"))
|
||||||
|
self.assertEqual(timeout_seconds, 900)
|
||||||
|
self.assertIn("--itemize-changes", command)
|
||||||
|
self.assertIn("--info=flist2,progress2,stats2", command)
|
||||||
|
self.assertIn("--stats", command)
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text(
|
||||||
|
"Number of files: 42\n"
|
||||||
|
"Number of regular files transferred: 3\n"
|
||||||
|
"Total file size: 1,000 bytes\n"
|
||||||
|
"Literal data: 100 bytes\n"
|
||||||
|
"Matched data: 900 bytes\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return RsyncResult(exit_code=0, command=command)
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=Path("/missing-prefix"),
|
||||||
|
host="web-01",
|
||||||
|
dry_run=True,
|
||||||
|
config_source=FakeConfigSource(),
|
||||||
|
run_id=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual(result["log"], "/tmp/pobsync-dryrun/web-01/run-42/rsync.log")
|
||||||
|
self.assertEqual(result["timeout_seconds"], 900)
|
||||||
|
self.assertEqual(result["stats"]["rsync"]["files_total"], 42)
|
||||||
|
self.assertEqual(result["stats"]["rsync"]["link_dest_estimated_savings_ratio"], 0.9)
|
||||||
|
|
||||||
|
def test_dry_run_does_not_duplicate_custom_output_args(self) -> None:
|
||||||
|
config_source = FakeConfigSource()
|
||||||
|
|
||||||
|
def effective_config_for_host(host: str) -> dict:
|
||||||
|
config = FakeConfigSource.effective_config_for_host(config_source, host)
|
||||||
|
config["rsync"]["args_effective"] = ["--archive", "--itemize-changes", "--info=name1,stats2"]
|
||||||
|
return config
|
||||||
|
|
||||||
|
config_source.effective_config_for_host = effective_config_for_host
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
|
||||||
|
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync"])
|
||||||
|
run_scheduled(
|
||||||
|
prefix=Path("/missing-prefix"),
|
||||||
|
host="web-01",
|
||||||
|
dry_run=True,
|
||||||
|
config_source=config_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
command = run_rsync.call_args.args[0]
|
||||||
|
self.assertEqual(command.count("--itemize-changes"), 1)
|
||||||
|
self.assertNotIn("--info=flist2,progress2,stats2", command)
|
||||||
|
self.assertIn("--info=name1,stats2", command)
|
||||||
|
|
||||||
|
def test_real_run_can_request_verbose_output_args(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
prefix = Path(tmp) / "home"
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
|
||||||
|
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=prefix,
|
||||||
|
host="web-01",
|
||||||
|
dry_run=False,
|
||||||
|
verbose_output=True,
|
||||||
|
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")),
|
||||||
|
)
|
||||||
|
|
||||||
|
command = run_rsync.call_args.args[0]
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertIn("--stats", command)
|
||||||
|
self.assertIn("--itemize-changes", command)
|
||||||
|
self.assertIn("--info=flist2,progress2,stats2", command)
|
||||||
|
self.assertTrue(result["verbose_output"])
|
||||||
|
|
||||||
|
def test_real_run_keeps_default_output_quiet(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
prefix = Path(tmp) / "home"
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
|
||||||
|
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=prefix,
|
||||||
|
host="web-01",
|
||||||
|
dry_run=False,
|
||||||
|
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")),
|
||||||
|
)
|
||||||
|
|
||||||
|
command = run_rsync.call_args.args[0]
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertIn("--stats", command)
|
||||||
|
self.assertNotIn("--itemize-changes", command)
|
||||||
|
self.assertNotIn("--info=flist2,progress2,stats2", command)
|
||||||
|
self.assertFalse(result["verbose_output"])
|
||||||
|
|
||||||
|
def test_successful_real_run_records_stats_in_result_and_metadata(self) -> None:
|
||||||
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text(
|
||||||
|
"Number of files: 10\n"
|
||||||
|
"Number of regular files transferred: 2\n"
|
||||||
|
"Total file size: 2,000 bytes\n"
|
||||||
|
"Total transferred file size: 500 bytes\n"
|
||||||
|
"Literal data: 500 bytes\n"
|
||||||
|
"Matched data: 1,500 bytes\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data_dir = log_path.parent.parent / "data"
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(data_dir / "payload.txt").write_text("payload", encoding="utf-8")
|
||||||
|
return RsyncResult(exit_code=0, command=command)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=Path(tmp) / "home",
|
||||||
|
host="web-01",
|
||||||
|
dry_run=False,
|
||||||
|
config_source=FakeConfigSource(backup_root=str(backup_root)),
|
||||||
|
)
|
||||||
|
|
||||||
|
meta_path = Path(result["snapshot"]) / "meta" / "meta.yaml"
|
||||||
|
meta_text = meta_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual(result["log"], str(Path(result["snapshot"]) / "meta" / "rsync.log"))
|
||||||
|
self.assertEqual(result["stats"]["rsync"]["files_total"], 10)
|
||||||
|
self.assertEqual(result["stats"]["rsync"]["files_transferred"], 2)
|
||||||
|
self.assertEqual(result["stats"]["rsync"]["link_dest_estimated_savings_bytes"], 1500)
|
||||||
|
self.assertIn("snapshot", result["stats"]["storage"])
|
||||||
|
self.assertIn("capacity", result["stats"]["storage"])
|
||||||
|
self.assertIn("stats:", meta_text)
|
||||||
|
self.assertIn("files_total: 10", meta_text)
|
||||||
|
|
||||||
|
def test_dry_run_reports_cancelled_rsync(self) -> None:
|
||||||
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
|
self.assertTrue(cancel_check())
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text("[pobsync] rsync cancelled\n", encoding="utf-8")
|
||||||
|
return RsyncResult(exit_code=130, command=command, cancelled=True)
|
||||||
|
|
||||||
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
||||||
|
result = run_scheduled(
|
||||||
|
prefix=Path("/missing-prefix"),
|
||||||
|
host="web-01",
|
||||||
|
dry_run=True,
|
||||||
|
config_source=FakeConfigSource(),
|
||||||
|
cancel_check=lambda: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertTrue(result["cancelled"])
|
||||||
|
self.assertEqual(result["rsync"]["exit_code"], 130)
|
||||||
|
|
||||||
def test_successful_real_run_applies_prune_when_requested(self) -> None:
|
def test_successful_real_run_applies_prune_when_requested(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
prefix = Path(tmp) / "home"
|
prefix = Path(tmp) / "home"
|
||||||
|
|||||||
60
src/pobsync_backend/tests/test_run_stats.py
Normal file
60
src/pobsync_backend/tests/test_run_stats.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from pobsync.run_stats import parse_rsync_stats, tree_usage
|
||||||
|
|
||||||
|
|
||||||
|
class RunStatsTests(SimpleTestCase):
|
||||||
|
def test_parse_rsync_stats_extracts_counts_bytes_and_savings(self) -> None:
|
||||||
|
stats = parse_rsync_stats(
|
||||||
|
"""
|
||||||
|
Number of files: 1,234 (reg: 1,200, dir: 34)
|
||||||
|
Number of created files: 12 (reg: 10, dir: 2)
|
||||||
|
Number of deleted files: 3
|
||||||
|
Number of regular files transferred: 8
|
||||||
|
Total file size: 1.50M bytes
|
||||||
|
Total transferred file size: 24.00K bytes
|
||||||
|
Literal data: 24.00K bytes
|
||||||
|
Matched data: 976.00K bytes
|
||||||
|
File list size: 8.00K
|
||||||
|
Total bytes sent: 10.00K
|
||||||
|
Total bytes received: 2.00K
|
||||||
|
sent 10.00K bytes received 2.00K bytes 1.20K bytes/sec
|
||||||
|
total size is 1.50M speedup is 125.00
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(stats["files_total"], 1234)
|
||||||
|
self.assertEqual(stats["files_created"], 12)
|
||||||
|
self.assertEqual(stats["files_deleted"], 3)
|
||||||
|
self.assertEqual(stats["files_transferred"], 8)
|
||||||
|
self.assertEqual(stats["total_file_size_bytes"], 1_500_000)
|
||||||
|
self.assertEqual(stats["total_transferred_file_size_bytes"], 24_000)
|
||||||
|
self.assertEqual(stats["literal_data_bytes"], 24_000)
|
||||||
|
self.assertEqual(stats["matched_data_bytes"], 976_000)
|
||||||
|
self.assertEqual(stats["bytes_sent_received"], 12_000)
|
||||||
|
self.assertEqual(stats["bytes_per_second"], 1_200)
|
||||||
|
self.assertEqual(stats["speedup"], 125.0)
|
||||||
|
self.assertEqual(stats["link_dest_estimated_savings_bytes"], 976_000)
|
||||||
|
self.assertEqual(stats["link_dest_estimated_savings_ratio"], 0.976)
|
||||||
|
|
||||||
|
def test_tree_usage_reports_hardlinked_files(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
source = root / "source"
|
||||||
|
linked = root / "linked"
|
||||||
|
source.write_bytes(b"abc")
|
||||||
|
os.link(source, linked)
|
||||||
|
|
||||||
|
stats = tree_usage(root)
|
||||||
|
|
||||||
|
self.assertEqual(stats["files"], 2)
|
||||||
|
self.assertEqual(stats["apparent_size_bytes"], 6)
|
||||||
|
self.assertEqual(stats["hardlinked_files"], 2)
|
||||||
|
self.assertEqual(stats["hardlinked_apparent_size_bytes"], 6)
|
||||||
|
self.assertEqual(stats["hardlink_apparent_ratio"], 1.0)
|
||||||
@@ -8,8 +8,8 @@ from zoneinfo import ZoneInfo
|
|||||||
from django.test import SimpleTestCase, TestCase
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
|
||||||
from pobsync_backend.management.commands.run_pobsync_scheduler import Command
|
from pobsync_backend.management.commands.run_pobsync_scheduler import Command
|
||||||
from pobsync_backend.models import HostConfig, ScheduleConfig
|
from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig
|
||||||
from pobsync_backend.scheduler import due_key, is_due
|
from pobsync_backend.scheduler import due_key, is_due, next_due_after
|
||||||
|
|
||||||
|
|
||||||
class SchedulerTests(SimpleTestCase):
|
class SchedulerTests(SimpleTestCase):
|
||||||
@@ -36,6 +36,12 @@ class SchedulerTests(SimpleTestCase):
|
|||||||
|
|
||||||
self.assertEqual(due_key(moment), "202605190215")
|
self.assertEqual(due_key(moment), "202605190215")
|
||||||
|
|
||||||
|
def test_next_due_after_returns_next_matching_minute(self) -> None:
|
||||||
|
moment = datetime(2026, 5, 19, 2, 15, 45, tzinfo=ZoneInfo("UTC"))
|
||||||
|
|
||||||
|
self.assertEqual(next_due_after("30 2 * * *", moment), datetime(2026, 5, 19, 2, 30, tzinfo=ZoneInfo("UTC")))
|
||||||
|
self.assertEqual(next_due_after("15 2 * * *", moment), datetime(2026, 5, 20, 2, 15, tzinfo=ZoneInfo("UTC")))
|
||||||
|
|
||||||
|
|
||||||
class SchedulerCommandTests(TestCase):
|
class SchedulerCommandTests(TestCase):
|
||||||
def test_run_due_executes_schedule_once_per_minute(self) -> None:
|
def test_run_due_executes_schedule_once_per_minute(self) -> None:
|
||||||
@@ -58,3 +64,30 @@ class SchedulerCommandTests(TestCase):
|
|||||||
self.assertEqual(call.call_count, 1)
|
self.assertEqual(call.call_count, 1)
|
||||||
schedule = ScheduleConfig.objects.get(host=host)
|
schedule = ScheduleConfig.objects.get(host=host)
|
||||||
self.assertEqual(schedule.last_status, "success")
|
self.assertEqual(schedule.last_status, "success")
|
||||||
|
|
||||||
|
def test_run_due_records_warning_status_from_scheduled_backup_run(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
ScheduleConfig.objects.create(host=host, cron_expr="* * * * *", prune=True, prune_max_delete=1)
|
||||||
|
|
||||||
|
def create_warning_run(*args, **kwargs) -> None:
|
||||||
|
BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
run_type=BackupRun.RunType.SCHEDULED,
|
||||||
|
status=BackupRun.Status.WARNING,
|
||||||
|
result={
|
||||||
|
"ok": True,
|
||||||
|
"prune": {
|
||||||
|
"ok": False,
|
||||||
|
"type": "ConfigError",
|
||||||
|
"error": "Refusing to delete 2 snapshots (exceeds --max-delete=1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
command = Command()
|
||||||
|
with patch("pobsync_backend.management.commands.run_pobsync_scheduler.call_command", side_effect=create_warning_run):
|
||||||
|
count = command._run_due(prefix=Path("/opt/pobsync"), dry_run=False)
|
||||||
|
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
schedule = ScheduleConfig.objects.get(host=host)
|
||||||
|
self.assertEqual(schedule.last_status, "warning")
|
||||||
|
|||||||
136
src/pobsync_backend/tests/test_self_check.py
Normal file
136
src/pobsync_backend/tests/test_self_check.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.test import SimpleTestCase, override_settings
|
||||||
|
|
||||||
|
from pobsync_backend.self_check import SelfCheck, _install_checks, _sqlite_database_check, _systemd_checks
|
||||||
|
|
||||||
|
|
||||||
|
class SystemdSelfCheckTests(SimpleTestCase):
|
||||||
|
def test_journal_permission_hint_is_reported_as_failure(self) -> None:
|
||||||
|
def which(binary: str) -> str | None:
|
||||||
|
if binary in {"systemctl", "journalctl"}:
|
||||||
|
return f"/usr/bin/{binary}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
active_result = subprocess.CompletedProcess(
|
||||||
|
args=["systemctl"],
|
||||||
|
returncode=0,
|
||||||
|
stdout="active\n",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
journal_result = subprocess.CompletedProcess(
|
||||||
|
args=["journalctl"],
|
||||||
|
returncode=0,
|
||||||
|
stdout="",
|
||||||
|
stderr="No journal files were opened due to insufficient permissions.",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.self_check.Path.exists", return_value=True), patch(
|
||||||
|
"pobsync_backend.self_check.shutil.which",
|
||||||
|
side_effect=which,
|
||||||
|
), patch(
|
||||||
|
"pobsync_backend.self_check.subprocess.run",
|
||||||
|
side_effect=[active_result, active_result, active_result, journal_result],
|
||||||
|
):
|
||||||
|
checks = _systemd_checks()
|
||||||
|
|
||||||
|
journal_check = next(check for check in checks if check.name == "Journal access")
|
||||||
|
self.assertEqual(journal_check.status, "failed")
|
||||||
|
self.assertEqual(journal_check.message, "pobsync cannot read service logs.")
|
||||||
|
|
||||||
|
|
||||||
|
class InstallSelfCheckTests(SimpleTestCase):
|
||||||
|
def test_install_checks_skip_native_paths_in_development_runtime(self) -> None:
|
||||||
|
with override_settings(POBSYNC_ENV_FILE="/missing/pobsync.env"), patch(
|
||||||
|
"pobsync_backend.self_check._native_runtime_available",
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
checks = _install_checks()
|
||||||
|
|
||||||
|
self.assertEqual([check.status for check in checks], ["skipped", "skipped", "skipped"])
|
||||||
|
self.assertEqual(checks[0].name, "Environment file")
|
||||||
|
self.assertEqual(checks[1].name, "Service user")
|
||||||
|
self.assertEqual(checks[2].name, "Backup root owner")
|
||||||
|
|
||||||
|
def test_service_user_warns_when_current_user_differs(self) -> None:
|
||||||
|
with override_settings(
|
||||||
|
POBSYNC_ENV_FILE="/etc/pobsync/pobsync.env",
|
||||||
|
POBSYNC_SERVICE_USER="pobsync",
|
||||||
|
POBSYNC_BACKUP_ROOT="/backups",
|
||||||
|
), patch("pobsync_backend.self_check._native_runtime_available", return_value=True), patch(
|
||||||
|
"pobsync_backend.self_check._env_file_check",
|
||||||
|
return_value=SelfCheck("Environment file", "ok", "/etc/pobsync/pobsync.env"),
|
||||||
|
), patch(
|
||||||
|
"pobsync_backend.self_check._backup_root_owner_check",
|
||||||
|
return_value=SelfCheck("Backup root owner", "ok", "/backups owner=pobsync"),
|
||||||
|
), patch(
|
||||||
|
"pobsync_backend.self_check.os.geteuid",
|
||||||
|
return_value=0,
|
||||||
|
), patch(
|
||||||
|
"pobsync_backend.self_check.pwd.getpwuid",
|
||||||
|
) as getpwuid:
|
||||||
|
getpwuid.return_value.pw_name = "root"
|
||||||
|
checks = _install_checks()
|
||||||
|
|
||||||
|
service_user_check = next(check for check in checks if check.name == "Service user")
|
||||||
|
self.assertEqual(service_user_check.status, "warning")
|
||||||
|
self.assertIn("expected pobsync", service_user_check.message)
|
||||||
|
|
||||||
|
def test_sqlite_database_check_reports_existing_database(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
db_path = Path(tmp) / "pobsync.sqlite3"
|
||||||
|
db_path.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
check = _sqlite_database_check(db_path)
|
||||||
|
|
||||||
|
self.assertEqual(check.status, "ok")
|
||||||
|
self.assertEqual(check.name, "SQLite database")
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPobsyncInstallCommandTests(SimpleTestCase):
|
||||||
|
def test_command_prints_summary_for_successful_checks(self) -> None:
|
||||||
|
stdout = StringIO()
|
||||||
|
stderr = StringIO()
|
||||||
|
checks = [
|
||||||
|
SelfCheck("Database connection", "ok", "django.db.backends.sqlite3"),
|
||||||
|
SelfCheck("Systemd services", "skipped", "systemd is not available in this runtime."),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("pobsync_backend.management.commands.check_pobsync_install.collect_self_checks", return_value=checks):
|
||||||
|
call_command("check_pobsync_install", stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
self.assertIn("[OK] Database connection", stdout.getvalue())
|
||||||
|
self.assertIn("[SKIPPED] Systemd services", stdout.getvalue())
|
||||||
|
self.assertIn("Summary: 1 ok, 0 warning(s), 0 failed, 1 skipped", stdout.getvalue())
|
||||||
|
self.assertEqual(stderr.getvalue(), "")
|
||||||
|
|
||||||
|
def test_command_fails_when_checks_fail(self) -> None:
|
||||||
|
stdout = StringIO()
|
||||||
|
stderr = StringIO()
|
||||||
|
checks = [
|
||||||
|
SelfCheck("POBSYNC_BACKUP_ROOT", "failed", "/backups does not exist."),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("pobsync_backend.management.commands.check_pobsync_install.collect_self_checks", return_value=checks):
|
||||||
|
with self.assertRaisesMessage(CommandError, "pobsync install self check failed."):
|
||||||
|
call_command("check_pobsync_install", stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
self.assertIn("[FAILED] POBSYNC_BACKUP_ROOT", stderr.getvalue())
|
||||||
|
self.assertIn("Summary: 0 ok, 0 warning(s), 1 failed, 0 skipped", stdout.getvalue())
|
||||||
|
|
||||||
|
def test_command_can_fail_on_warnings(self) -> None:
|
||||||
|
checks = [
|
||||||
|
SelfCheck("Global config", "warning", "Default global config has not been created yet."),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("pobsync_backend.management.commands.check_pobsync_install.collect_self_checks", return_value=checks):
|
||||||
|
with self.assertRaisesMessage(CommandError, "pobsync install self check reported warnings."):
|
||||||
|
call_command("check_pobsync_install", "--fail-on-warning", stdout=StringIO(), stderr=StringIO())
|
||||||
@@ -10,7 +10,13 @@ from django.test import TestCase
|
|||||||
|
|
||||||
from pobsync.util import write_yaml_atomic
|
from pobsync.util import write_yaml_atomic
|
||||||
from pobsync_backend.models import GlobalConfig, HostConfig, SnapshotRecord
|
from pobsync_backend.models import GlobalConfig, HostConfig, SnapshotRecord
|
||||||
from pobsync_backend.snapshot_discovery import discover_snapshots, parse_snapshot_datetime
|
from pobsync_backend.snapshot_discovery import (
|
||||||
|
discover_snapshots,
|
||||||
|
inspect_snapshot_discovery,
|
||||||
|
parse_snapshot_datetime,
|
||||||
|
resolve_base_links,
|
||||||
|
upsert_snapshot_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SnapshotDiscoveryTests(TestCase):
|
class SnapshotDiscoveryTests(TestCase):
|
||||||
@@ -58,6 +64,106 @@ class SnapshotDiscoveryTests(TestCase):
|
|||||||
self.assertEqual(record.kind, "scheduled")
|
self.assertEqual(record.kind, "scheduled")
|
||||||
self.assertEqual(record.started_at, datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc))
|
self.assertEqual(record.started_at, datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
def test_inspect_snapshot_discovery_reports_missing_global_config(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
result = inspect_snapshot_discovery(host=host)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(result["reason"], "missing_global_config")
|
||||||
|
self.assertEqual(result["total_candidates"], 0)
|
||||||
|
|
||||||
|
def test_inspect_snapshot_discovery_counts_snapshot_directories(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
(backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH").mkdir(parents=True)
|
||||||
|
(backup_root / host.host / "manual" / "20260519-031500Z__MANUAL01").mkdir(parents=True)
|
||||||
|
|
||||||
|
result = inspect_snapshot_discovery(host=host)
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual(result["reason"], "ready")
|
||||||
|
self.assertEqual(result["total_candidates"], 2)
|
||||||
|
self.assertEqual(result["kind_counts"], {"scheduled": 1, "manual": 1, "incomplete": 0})
|
||||||
|
|
||||||
|
def test_discovery_links_snapshot_to_base_record(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
base_dir = backup_root / host.host / "scheduled" / "20260518-021500Z__BASESNAP"
|
||||||
|
child_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__CHILDSNP"
|
||||||
|
(base_dir / "meta").mkdir(parents=True)
|
||||||
|
(child_dir / "meta").mkdir(parents=True)
|
||||||
|
write_yaml_atomic(
|
||||||
|
base_dir / "meta" / "meta.yaml",
|
||||||
|
{
|
||||||
|
"id": "base-id",
|
||||||
|
"status": "success",
|
||||||
|
"started_at": "2026-05-18T02:15:00Z",
|
||||||
|
"base": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
write_yaml_atomic(
|
||||||
|
child_dir / "meta" / "meta.yaml",
|
||||||
|
{
|
||||||
|
"id": "child-id",
|
||||||
|
"status": "success",
|
||||||
|
"started_at": "2026-05-19T02:15:00Z",
|
||||||
|
"base": {
|
||||||
|
"kind": "scheduled",
|
||||||
|
"dirname": base_dir.name,
|
||||||
|
"id": "base-id",
|
||||||
|
"path": str(base_dir / "data"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = discover_snapshots(host=host)
|
||||||
|
|
||||||
|
self.assertEqual(result["created"], 2)
|
||||||
|
child = SnapshotRecord.objects.get(dirname=child_dir.name)
|
||||||
|
base = SnapshotRecord.objects.get(dirname=base_dir.name)
|
||||||
|
self.assertEqual(child.base, base)
|
||||||
|
self.assertEqual(child.base_kind, "scheduled")
|
||||||
|
self.assertEqual(child.base_dirname, base_dir.name)
|
||||||
|
self.assertEqual(child.base_snapshot_id, "base-id")
|
||||||
|
self.assertEqual(child.base_path, str(base_dir / "data"))
|
||||||
|
|
||||||
|
def test_base_link_can_be_resolved_after_base_record_exists(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
base_dir = backup_root / host.host / "scheduled" / "20260518-021500Z__BASESNAP"
|
||||||
|
child_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__CHILDSNP"
|
||||||
|
(base_dir / "meta").mkdir(parents=True)
|
||||||
|
(child_dir / "meta").mkdir(parents=True)
|
||||||
|
write_yaml_atomic(base_dir / "meta" / "meta.yaml", {"status": "success"})
|
||||||
|
write_yaml_atomic(
|
||||||
|
child_dir / "meta" / "meta.yaml",
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"base": {
|
||||||
|
"kind": "scheduled",
|
||||||
|
"dirname": base_dir.name,
|
||||||
|
"id": "base-id",
|
||||||
|
"path": str(base_dir / "data"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
child, _created = upsert_snapshot_record(host=host, kind="scheduled", snapshot_dir=child_dir)
|
||||||
|
upsert_snapshot_record(host=host, kind="scheduled", snapshot_dir=base_dir)
|
||||||
|
linked = resolve_base_links(host=host)
|
||||||
|
|
||||||
|
child.refresh_from_db()
|
||||||
|
self.assertEqual(linked, 1)
|
||||||
|
self.assertIsNotNone(child.base)
|
||||||
|
self.assertEqual(child.base.dirname, base_dir.name)
|
||||||
|
self.assertEqual(child.base_dirname, base_dir.name)
|
||||||
|
|
||||||
def test_command_discovers_snapshots_for_host(self) -> None:
|
def test_command_discovers_snapshots_for_host(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
backup_root = Path(tmp) / "backups"
|
backup_root = Path(tmp) / "backups"
|
||||||
|
|||||||
293
src/pobsync_backend/tests/test_sql_retention.py
Normal file
293
src/pobsync_backend/tests/test_sql_retention.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from pobsync.errors import ConfigError
|
||||||
|
from pobsync_backend.models import HostConfig, PurgedSnapshot, SnapshotRecord
|
||||||
|
from pobsync_backend.retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
|
||||||
|
|
||||||
|
|
||||||
|
class SqlRetentionTests(TestCase):
|
||||||
|
def test_plan_uses_snapshot_records(self) -> None:
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
old = self._snapshot(host, "20260518-021500Z__OLD")
|
||||||
|
new = self._snapshot(host, "20260519-021500Z__NEW")
|
||||||
|
|
||||||
|
plan = run_sql_retention_plan(host=host.host, kind="scheduled", protect_bases=False)
|
||||||
|
|
||||||
|
self.assertEqual(plan["source"], "sql")
|
||||||
|
self.assertEqual(plan["keep"], [new.dirname])
|
||||||
|
self.assertEqual([item["dirname"] for item in plan["keep_items"]], [new.dirname])
|
||||||
|
self.assertEqual([item["dirname"] for item in plan["delete"]], [old.dirname])
|
||||||
|
self.assertEqual(plan["delete"][0]["reason"], "outside retention policy")
|
||||||
|
self.assertEqual(plan["incomplete"], [])
|
||||||
|
|
||||||
|
def test_plan_can_protect_base_snapshot_from_sql_relation(self) -> None:
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
base = self._snapshot(host, "20260518-021500Z__BASE")
|
||||||
|
child = self._snapshot(host, "20260519-021500Z__CHILD", base=base)
|
||||||
|
|
||||||
|
plan = run_sql_retention_plan(host=host.host, kind="scheduled", protect_bases=True)
|
||||||
|
|
||||||
|
self.assertEqual(plan["keep"], [base.dirname, child.dirname])
|
||||||
|
self.assertEqual(plan["delete"], [])
|
||||||
|
self.assertEqual(plan["reasons"][base.dirname], [f"base-of:{child.dirname}"])
|
||||||
|
|
||||||
|
def test_apply_deletes_snapshot_directory_and_record(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
prefix = Path(tmp) / "home"
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
old_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260518-021500Z__OLD"
|
||||||
|
new_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260519-021500Z__NEW"
|
||||||
|
old_dir.mkdir(parents=True)
|
||||||
|
new_dir.mkdir(parents=True)
|
||||||
|
old = self._snapshot(host, old_dir.name, path=str(old_dir))
|
||||||
|
new = self._snapshot(host, new_dir.name, path=str(new_dir))
|
||||||
|
|
||||||
|
result = run_sql_retention_apply(
|
||||||
|
prefix=prefix,
|
||||||
|
host=host.host,
|
||||||
|
kind="scheduled",
|
||||||
|
protect_bases=False,
|
||||||
|
yes=True,
|
||||||
|
max_delete=1,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(old_dir.exists())
|
||||||
|
self.assertTrue(new_dir.exists())
|
||||||
|
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
|
||||||
|
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
|
||||||
|
self.assertEqual(
|
||||||
|
result["deleted"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dirname": old.dirname,
|
||||||
|
"kind": "scheduled",
|
||||||
|
"path": str(old_dir),
|
||||||
|
"reason": "outside retention policy",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["planned_delete_count"], 1)
|
||||||
|
self.assertEqual(result["max_delete"], 1)
|
||||||
|
self.assertEqual(result["incomplete_ignored_count"], 0)
|
||||||
|
purged = PurgedSnapshot.objects.get(dirname=old.dirname)
|
||||||
|
self.assertEqual(purged.host_name, host.host)
|
||||||
|
self.assertEqual(purged.kind, "scheduled")
|
||||||
|
self.assertEqual(purged.path, str(old_dir))
|
||||||
|
self.assertEqual(purged.reason, "outside retention policy")
|
||||||
|
self.assertEqual(purged.action, PurgedSnapshot.Action.MANUAL)
|
||||||
|
|
||||||
|
def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
prefix = Path(tmp) / "home"
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
old_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260518-021500Z__OLD"
|
||||||
|
old_data = old_dir / "data"
|
||||||
|
old_data.mkdir(parents=True)
|
||||||
|
old_data.joinpath("etc").mkdir()
|
||||||
|
old_data.joinpath("etc", "config").write_text("preserved permissions\n")
|
||||||
|
old_data.chmod(stat.S_IREAD | stat.S_IEXEC | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||||
|
new_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260519-021500Z__NEW"
|
||||||
|
new_dir.mkdir(parents=True)
|
||||||
|
old = self._snapshot(host, old_dir.name, path=str(old_data))
|
||||||
|
self._snapshot(host, new_dir.name, path=str(new_dir))
|
||||||
|
|
||||||
|
result = run_sql_retention_apply(
|
||||||
|
prefix=prefix,
|
||||||
|
host=host.host,
|
||||||
|
kind="scheduled",
|
||||||
|
protect_bases=False,
|
||||||
|
yes=True,
|
||||||
|
max_delete=1,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(old_dir.exists())
|
||||||
|
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
|
||||||
|
self.assertEqual(
|
||||||
|
result["deleted"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dirname": old.dirname,
|
||||||
|
"kind": "scheduled",
|
||||||
|
"path": str(old_dir),
|
||||||
|
"reason": "outside retention policy",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_respects_max_delete(self) -> None:
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
self._snapshot(host, "20260517-021500Z__OLDER")
|
||||||
|
self._snapshot(host, "20260518-021500Z__OLD")
|
||||||
|
self._snapshot(host, "20260519-021500Z__NEW")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ConfigError, "exceeds --max-delete=1"):
|
||||||
|
run_sql_retention_apply(
|
||||||
|
prefix=Path("/tmp/pobsync-test"),
|
||||||
|
host=host.host,
|
||||||
|
kind="scheduled",
|
||||||
|
protect_bases=False,
|
||||||
|
yes=True,
|
||||||
|
max_delete=1,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incomplete_cleanup_deletes_directory_and_record(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
prefix = Path(tmp) / "home"
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
incomplete_dir = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-031500Z__BROKEN01"
|
||||||
|
incomplete_dir.mkdir(parents=True)
|
||||||
|
incomplete_dir.joinpath("partial-file").write_text("interrupted\n")
|
||||||
|
record = SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname=incomplete_dir.name,
|
||||||
|
path=str(incomplete_dir),
|
||||||
|
status="failed",
|
||||||
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_incomplete_cleanup(
|
||||||
|
prefix=prefix,
|
||||||
|
host=host.host,
|
||||||
|
yes=True,
|
||||||
|
max_delete=1,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(incomplete_dir.exists())
|
||||||
|
self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
|
||||||
|
self.assertEqual(
|
||||||
|
result["deleted"],
|
||||||
|
[{"dirname": incomplete_dir.name, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(incomplete_dir)}],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["planned_delete_count"], 1)
|
||||||
|
purged = PurgedSnapshot.objects.get(dirname=incomplete_dir.name)
|
||||||
|
self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
|
||||||
|
self.assertEqual(purged.reason, "manual incomplete cleanup")
|
||||||
|
|
||||||
|
def test_incomplete_cleanup_respects_max_delete(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname="20260519-031500Z__BROKEN01",
|
||||||
|
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||||
|
status="failed",
|
||||||
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ConfigError, "blocked by --max-delete=0"):
|
||||||
|
run_incomplete_cleanup(
|
||||||
|
prefix=Path("/tmp/pobsync-test"),
|
||||||
|
host=host.host,
|
||||||
|
yes=True,
|
||||||
|
max_delete=0,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incomplete_cleanup_rejects_unexpected_path(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname="20260519-031500Z__BROKEN01",
|
||||||
|
path=f"/backups/{host.host}/scheduled/20260519-031500Z__BROKEN01",
|
||||||
|
status="failed",
|
||||||
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ConfigError, "unexpected incomplete snapshot path"):
|
||||||
|
run_incomplete_cleanup(
|
||||||
|
prefix=Path("/tmp/pobsync-test"),
|
||||||
|
host=host.host,
|
||||||
|
yes=True,
|
||||||
|
max_delete=1,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_management_command_plans_from_sql(self) -> None:
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
old = self._snapshot(host, "20260518-021500Z__OLD")
|
||||||
|
new = self._snapshot(host, "20260519-021500Z__NEW")
|
||||||
|
stdout = StringIO()
|
||||||
|
|
||||||
|
call_command("run_pobsync_retention", host.host, stdout=stdout)
|
||||||
|
|
||||||
|
result = json.loads(stdout.getvalue())
|
||||||
|
self.assertEqual(result["source"], "sql")
|
||||||
|
self.assertEqual(result["keep"], [new.dirname])
|
||||||
|
self.assertEqual([item["dirname"] for item in result["delete"]], [old.dirname])
|
||||||
|
|
||||||
|
def _snapshot(
|
||||||
|
self,
|
||||||
|
host: HostConfig,
|
||||||
|
dirname: str,
|
||||||
|
*,
|
||||||
|
path: str | None = None,
|
||||||
|
base: SnapshotRecord | None = None,
|
||||||
|
) -> SnapshotRecord:
|
||||||
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
return SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind="scheduled",
|
||||||
|
dirname=dirname,
|
||||||
|
path=path or f"/backups/{host.host}/scheduled/{dirname}",
|
||||||
|
base=base,
|
||||||
|
status="success",
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
74
src/pobsync_backend/tests/test_ssh_credentials.py
Normal file
74
src/pobsync_backend/tests/test_ssh_credentials.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import SimpleTestCase, TestCase, override_settings
|
||||||
|
|
||||||
|
from pobsync_backend.forms import normalize_private_key, validate_ssh_private_key
|
||||||
|
from pobsync_backend.models import GlobalConfig, SshCredential
|
||||||
|
from pobsync_backend.ssh_keys import merge_known_hosts
|
||||||
|
|
||||||
|
|
||||||
|
class SshCredentialValidationTests(SimpleTestCase):
|
||||||
|
def test_normalize_private_key_repairs_wrapped_openssh_body(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
key_path = Path(tmp) / "identity"
|
||||||
|
subprocess.run(
|
||||||
|
["ssh-keygen", "-t", "ed25519", "-N", "", "-C", "test", "-f", str(key_path)],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
private_key = key_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
begin_marker = "-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
end_marker = "-----END OPENSSH PRIVATE KEY-----"
|
||||||
|
body = private_key.split(begin_marker, 1)[1].split(end_marker, 1)[0]
|
||||||
|
damaged_body = " \n ".join(body.split())
|
||||||
|
damaged_key = f"{begin_marker}\n{damaged_body}\n{end_marker}"
|
||||||
|
|
||||||
|
normalized_key = normalize_private_key(damaged_key)
|
||||||
|
|
||||||
|
self.assertEqual(validate_ssh_private_key(normalized_key), validate_ssh_private_key(private_key))
|
||||||
|
|
||||||
|
def test_validate_private_key_rejects_pem_key_with_actionable_message(self) -> None:
|
||||||
|
with self.assertRaises(forms.ValidationError) as exc:
|
||||||
|
validate_ssh_private_key("-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----")
|
||||||
|
|
||||||
|
self.assertIn("PEM private keys are not supported", str(exc.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class SshCredentialManagementTests(TestCase):
|
||||||
|
def test_ensure_ssh_key_command_generates_default_key(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||||
|
call_command("ensure_pobsync_ssh_key", "--name", "default")
|
||||||
|
|
||||||
|
credential = SshCredential.objects.get(name="default")
|
||||||
|
self.assertTrue(credential.generated)
|
||||||
|
self.assertTrue(Path(credential.key_path).exists())
|
||||||
|
self.assertTrue(credential.public_key.startswith("ssh-ed25519 "))
|
||||||
|
|
||||||
|
def test_ensure_ssh_key_command_sets_global_default_when_available(self) -> None:
|
||||||
|
global_config = GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||||
|
|
||||||
|
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||||
|
call_command("ensure_pobsync_ssh_key", "--name", "default", "--set-global-default")
|
||||||
|
|
||||||
|
global_config.refresh_from_db()
|
||||||
|
self.assertEqual(global_config.default_ssh_credential.name, "default")
|
||||||
|
|
||||||
|
def test_merge_known_hosts_appends_unique_entries(self) -> None:
|
||||||
|
merged = merge_known_hosts(
|
||||||
|
"web-01.example.test ssh-ed25519 AAAAOLD\n",
|
||||||
|
"web-01.example.test ssh-ed25519 AAAAOLD\nweb-01.example.test ssh-rsa AAAANEW\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
merged,
|
||||||
|
"web-01.example.test ssh-ed25519 AAAAOLD\nweb-01.example.test ssh-rsa AAAANEW\n",
|
||||||
|
)
|
||||||
2220
src/pobsync_backend/tests/test_views.py
Normal file
2220
src/pobsync_backend/tests/test_views.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user