Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba798284df | ||
|
|
9ae93cfbc2 | ||
|
|
728b5771cc | ||
|
|
f55a44f2e6 | ||
|
|
bf752a904f |
@@ -1,8 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
.pytest_cache/
|
|
||||||
*.db
|
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
Binary file not shown.
+24
-3
@@ -5,6 +5,9 @@ from pathlib import Path
|
|||||||
# The modal shows this many days of run history; retention can never be shorter.
|
# The modal shows this many days of run history; retention can never be shorter.
|
||||||
HISTORY_DAYS = 30
|
HISTORY_DAYS = 30
|
||||||
|
|
||||||
|
# Shared by the systemd service (EnvironmentFile=) and manage.py, so both always use the same DB.
|
||||||
|
DEFAULT_ENV_FILE = "/etc/backup-dashboard/backup-dashboard.env"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
@@ -14,9 +17,27 @@ class Settings:
|
|||||||
serve_static: bool = False
|
serve_static: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def read_env_file(path: Path) -> dict[str, str]:
|
||||||
|
"""Parse simple KEY=VALUE lines (the subset systemd's EnvironmentFile accepts)."""
|
||||||
|
values = {}
|
||||||
|
try:
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return values
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
values[key.strip()] = value.strip().strip("\"'")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> Settings:
|
def load_settings() -> Settings:
|
||||||
|
# Real environment variables win over the env file; the env file wins over dev defaults.
|
||||||
|
env = read_env_file(Path(os.environ.get("BACKUP_DASH_ENV_FILE", DEFAULT_ENV_FILE))) | os.environ
|
||||||
return Settings(
|
return Settings(
|
||||||
db_path=Path(os.environ.get("BACKUP_DASH_DB", "backup-dashboard.db")),
|
db_path=Path(env.get("BACKUP_DASH_DB", "backup-dashboard.db")),
|
||||||
retention_days=max(HISTORY_DAYS, int(os.environ.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
|
retention_days=max(HISTORY_DAYS, int(env.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
|
||||||
serve_static=os.environ.get("BACKUP_DASH_SERVE_STATIC") == "1",
|
serve_static=env.get("BACKUP_DASH_SERVE_STATIC") == "1",
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.settings import load_settings
|
||||||
|
|
||||||
|
BACKEND = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run_manage(*args, env, cwd):
|
||||||
|
return subprocess.run([sys.executable, str(BACKEND / "manage.py"), *args],
|
||||||
|
capture_output=True, text=True, env=env, cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
def base_env(tmp_path, monkeypatch):
|
||||||
|
env_file = tmp_path / "backup-dashboard.env"
|
||||||
|
monkeypatch.setenv("BACKUP_DASH_ENV_FILE", str(env_file))
|
||||||
|
monkeypatch.delenv("BACKUP_DASH_DB", raising=False)
|
||||||
|
return env_file, {"BACKUP_DASH_ENV_FILE": str(env_file), "PYTHONPATH": str(BACKEND), "PATH": "/usr/bin:/bin"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_file_supplies_db_path_and_real_env_wins(tmp_path, monkeypatch):
|
||||||
|
env_file, _ = base_env(tmp_path, monkeypatch)
|
||||||
|
env_file.write_text('# comment\nBACKUP_DASH_DB="/srv/from-file.db"\nBACKUP_DASH_RETENTION_DAYS=120\n')
|
||||||
|
assert load_settings().db_path == Path("/srv/from-file.db")
|
||||||
|
assert load_settings().retention_days == 120
|
||||||
|
monkeypatch.setenv("BACKUP_DASH_DB", "/srv/from-env.db")
|
||||||
|
assert load_settings().db_path == Path("/srv/from-env.db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_refuses_to_create_db_implicitly(tmp_path, monkeypatch):
|
||||||
|
_, env = base_env(tmp_path, monkeypatch)
|
||||||
|
workdir = tmp_path / "somewhere-else"
|
||||||
|
workdir.mkdir()
|
||||||
|
r = run_manage("create-key", "k", env=env, cwd=workdir)
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "Database not found" in r.stderr
|
||||||
|
assert not (workdir / "backup-dashboard.db").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_uses_db_from_env_file(tmp_path, monkeypatch):
|
||||||
|
env_file, env = base_env(tmp_path, monkeypatch)
|
||||||
|
service_db = tmp_path / "service.db"
|
||||||
|
env_file.write_text(f"BACKUP_DASH_DB={service_db}\n")
|
||||||
|
assert run_manage("--create", "list-keys", env=env, cwd=tmp_path).returncode == 0 # service's first start
|
||||||
|
workdir = tmp_path / "somewhere-else"
|
||||||
|
workdir.mkdir()
|
||||||
|
r = run_manage("create-key", "veeam-acme", env=env, cwd=workdir)
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
assert str(service_db) in r.stderr
|
||||||
|
assert "veeam-acme" in run_manage("list-keys", env=env, cwd=tmp_path).stdout
|
||||||
|
assert not (workdir / "backup-dashboard.db").exists()
|
||||||
@@ -5,19 +5,25 @@ It covers jobs returned by `Get-VBRJob`: VM backup, replication and backup copy
|
|||||||
|
|
||||||
## Install (on the VBR server, as an administrator)
|
## Install (on the VBR server, as an administrator)
|
||||||
|
|
||||||
|
Requires PowerShell 7 installed machine-wide (`winget install Microsoft.PowerShell` or the MSI),
|
||||||
|
which VBR 13's Veeam module expects. The Microsoft Store version won't work, because the SYSTEM
|
||||||
|
account can't run it. The installer checks for this before changing anything.
|
||||||
|
|
||||||
1. On the dashboard host, create a key for this server:
|
1. On the dashboard host, create a key for this server:
|
||||||
```bash
|
```bash
|
||||||
python manage.py create-key veeam-acme
|
python manage.py create-key veeam-acme
|
||||||
```
|
```
|
||||||
2. Copy this folder to the VBR server and run:
|
2. Copy this folder to the VBR server and run, from PowerShell 7 (`pwsh`):
|
||||||
```powershell
|
```powershell
|
||||||
.\Install-VeeamCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
|
.\Install-VeeamCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
|
||||||
```
|
```
|
||||||
It prompts for the key, then:
|
It prompts for the key, then:
|
||||||
- writes `C:\ProgramData\BackupDashboard\veeam-collector.json` (readable only by SYSTEM and Administrators)
|
- writes `C:\ProgramData\BackupDashboard\veeam-collector.json` (readable only by SYSTEM and Administrators)
|
||||||
- copies the script to `C:\Program Files\BackupDashboard\`
|
- copies the script to `C:\Program Files\BackupDashboard\`
|
||||||
- registers the scheduled task **Backup Dashboard - Veeam collector** (SYSTEM, every 15 minutes)
|
- registers the scheduled task **Backup Dashboard - Veeam collector** (SYSTEM, every 15 minutes,
|
||||||
3. Preview the payloads, then backfill 30 days so the history view is populated straight away:
|
run by `C:\Program Files\PowerShell\7\pwsh.exe`; use `-PwshPath` if it's installed elsewhere)
|
||||||
|
3. Preview the payloads, then backfill 30 days so the history view is populated straight away
|
||||||
|
(from `pwsh`):
|
||||||
```powershell
|
```powershell
|
||||||
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -DryRun
|
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -DryRun
|
||||||
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -LookbackHours 720
|
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -LookbackHours 720
|
||||||
@@ -38,6 +44,10 @@ delete the scheduled task and both `BackupDashboard` folders.
|
|||||||
| Message | Session reason, each VM or object that didn't succeed, and warning/error lines from the session log |
|
| Message | Session reason, each VM or object that didn't succeed, and warning/error lines from the session log |
|
||||||
| Size | `BackupStats.BackupSize` |
|
| Size | `BackupStats.BackupSize` |
|
||||||
|
|
||||||
|
**Backup copy jobs (VBR 12+):** Veeam records their sessions under hidden per-source "worker" jobs.
|
||||||
|
The collector files each worker session under its copy job (via the worker's `PolicyJobId`), so a
|
||||||
|
copy job gets one run per source job it copies, with the source in `copied_from`.
|
||||||
|
|
||||||
Each run covers the last 48 hours (`LookbackHours`), so a few missed runs (server rebooting, API
|
Each run covers the last 48 hours (`LookbackHours`), so a few missed runs (server rebooting, API
|
||||||
down) fill in automatically. Sessions already delivered are recorded in
|
down) fill in automatically. Sessions already delivered are recorded in
|
||||||
`veeam-collector.state.json`. Running sessions are re-sent until they finish.
|
`veeam-collector.state.json`. Running sessions are re-sent until they finish.
|
||||||
@@ -48,6 +58,9 @@ down) fill in automatically. Sessions already delivered are recorded in
|
|||||||
- Exit codes: `0` OK, `1` some jobs failed to send (they're retried next run), `2` setup failed
|
- Exit codes: `0` OK, `1` some jobs failed to send (they're retried next run), `2` setup failed
|
||||||
(config, Veeam module, or Veeam connection).
|
(config, Veeam module, or Veeam connection).
|
||||||
- `API key rejected`: the key was revoked or mistyped. Re-run the installer with a new key.
|
- `API key rejected`: the key was revoked or mistyped. Re-run the installer with a new key.
|
||||||
|
- A job shows **Unknown** on the dashboard: the collector sent the job but matched no sessions to it.
|
||||||
|
Run `.\Get-VeeamCollectorDiagnostics.ps1` (read-only). It lists sessions whose job ID matches no
|
||||||
|
job, plus the type and link properties of the jobs left without sessions.
|
||||||
- `Get-VBRBackupSession` loads every session in the VBR database. On a server with years of history,
|
- `Get-VBRBackupSession` loads every session in the VBR database. On a server with years of history,
|
||||||
this can make each run take a minute or more. The task's 30-minute limit and "don't start if
|
this can make each run take a minute or more. The task's 30-minute limit and "don't start if
|
||||||
already running" setting keep that safe.
|
already running" setting keep that safe.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ After=network.target
|
|||||||
User=backupdash
|
User=backupdash
|
||||||
Group=backupdash
|
Group=backupdash
|
||||||
WorkingDirectory=/opt/backup-dashboard/backend
|
WorkingDirectory=/opt/backup-dashboard/backend
|
||||||
Environment=BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db
|
# Settings live in this file so manage.py reads exactly the same ones (see deploy/backup-dashboard.env)
|
||||||
Environment=BACKUP_DASH_RETENTION_DAYS=90
|
EnvironmentFile=/etc/backup-dashboard/backup-dashboard.env
|
||||||
# Bound to localhost: only nginx can reach it, so nginx's auth can't be bypassed from outside.
|
# Bound to localhost: only nginx can reach it, so nginx's auth can't be bypassed from outside.
|
||||||
# One worker is plenty at this scale and keeps SQLite writes simple.
|
# One worker is plenty at this scale and keeps SQLite writes simple.
|
||||||
ExecStart=/opt/backup-dashboard/venv/bin/uvicorn --factory app.main:create_app --host 127.0.0.1 --port 8000 --proxy-headers
|
ExecStart=/opt/backup-dashboard/venv/bin/uvicorn --factory app.main:create_app --host 127.0.0.1 --port 8000 --proxy-headers
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Install as /etc/backup-dashboard/backup-dashboard.env
|
||||||
|
# Read by both the API service and manage.py, so they always use the same database.
|
||||||
|
BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db
|
||||||
|
BACKUP_DASH_RETENTION_DAYS=90
|
||||||
Reference in New Issue
Block a user