Initial pve cloud backup app
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
# Runtime state
|
||||
/data/
|
||||
/logs/
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
|
||||
# Generated frontend build
|
||||
/static/
|
||||
|
||||
# Python
|
||||
/backend/.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Node/Vite
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Local/editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment/secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,9 @@
|
||||
# Agent notes
|
||||
|
||||
- Keep runtime configuration in SQLite only. Do not add `.env`, YAML, JSON, TOML, or other runtime config files.
|
||||
- Do not use Docker or add distributed-worker infrastructure.
|
||||
- Do not call `vzdump` directly. Use `pvesh` for backup operations.
|
||||
- Do not require a crypt remote. Plain OneDrive remotes and crypt remotes are both allowed.
|
||||
- Do not use `rclone sync`. Use exact-object operations: `copyto`, existence check, and `deletefile`.
|
||||
- Never delete a local archive unless upload completed and the remote object was verified.
|
||||
- Mock `pvesh` and `rclone` in automated tests. Manual integration testing is limited to `scripts/integration-test-adguard.sh`.
|
||||
@@ -0,0 +1,138 @@
|
||||
# PVE Cloud Backup
|
||||
|
||||
Self-hosted Proxmox VE backup manager for uploads to an existing rclone remote such as OneDrive.
|
||||
|
||||
The application is intentionally small:
|
||||
|
||||
- FastAPI backend and worker
|
||||
- Vue 3 + TypeScript + Tailwind frontend, compiled once into `static/`
|
||||
- SQLite for all runtime configuration and state
|
||||
- `pvesh` for Proxmox API access
|
||||
- `rclone copyto`, `rclone lsjson`, and `rclone deletefile` for exact remote objects
|
||||
- systemd services, no Docker
|
||||
|
||||
## Layout
|
||||
|
||||
All application files live under:
|
||||
|
||||
```text
|
||||
/opt/pve-cloud-backup/
|
||||
backend/
|
||||
frontend/
|
||||
static/
|
||||
data/
|
||||
app.db
|
||||
logs/
|
||||
scripts/
|
||||
```
|
||||
|
||||
The install script copies the two systemd service files to `/etc/systemd/system/`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install these on the Proxmox host:
|
||||
|
||||
- Python 3 with `venv`
|
||||
- Node.js and npm for the one-time frontend build
|
||||
- `rclone`
|
||||
- a preconfigured rclone remote, for example OneDrive or a crypt remote backed by OneDrive
|
||||
- working `pvesh` access as the service user
|
||||
|
||||
The app does not configure rclone. If no remote exists, setup fails until you configure rclone yourself.
|
||||
|
||||
## Install
|
||||
|
||||
From the installation directory:
|
||||
|
||||
```bash
|
||||
cd /opt/pve-cloud-backup
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
1. creates required subdirectories,
|
||||
2. creates a Python virtual environment,
|
||||
3. installs Python dependencies,
|
||||
4. builds the Vue frontend into `/opt/pve-cloud-backup/static`,
|
||||
5. initializes SQLite migrations,
|
||||
6. installs and starts systemd services.
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://<proxmox-host>:8080
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
On first launch, complete the setup wizard:
|
||||
|
||||
- Proxmox node
|
||||
- Proxmox backup storage
|
||||
- local backup directory
|
||||
- rclone executable path
|
||||
- rclone remote name
|
||||
- remote backup path/folder within that remote
|
||||
- Discord webhook URL
|
||||
- allowed CORS origins
|
||||
- default compression
|
||||
- default backup mode
|
||||
- default retention policy
|
||||
- max concurrent backups
|
||||
|
||||
All settings are stored in SQLite at `/opt/pve-cloud-backup/data/app.db`.
|
||||
|
||||
## Recovery behavior
|
||||
|
||||
On worker startup, unfinished records in these states are reconciled:
|
||||
|
||||
- `pve_running`
|
||||
- `local_ready`
|
||||
- `uploading`
|
||||
- `remote_ready`
|
||||
- `local_deleting`
|
||||
- `deleting`
|
||||
|
||||
The worker does not delete unknown local files or remote objects. Local archives are deleted only after upload finishes and the remote object is verified.
|
||||
|
||||
## Retention cleanup
|
||||
|
||||
Retention cleanup runs in two places:
|
||||
|
||||
- immediately after a backup completes for that job;
|
||||
- periodically from the worker service, once per hour, so changed rules and day-based expiry are enforced even if no new backup runs.
|
||||
|
||||
Remote deletion uses exact-object `rclone deletefile` calls for backup records already known in SQLite.
|
||||
|
||||
## Testing
|
||||
|
||||
Run backend tests:
|
||||
|
||||
```bash
|
||||
cd /opt/pve-cloud-backup/backend
|
||||
./.venv/bin/pytest
|
||||
```
|
||||
|
||||
Run frontend type check and build:
|
||||
|
||||
```bash
|
||||
cd /opt/pve-cloud-backup/frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Manual integration test for the provided Proxmox test target:
|
||||
|
||||
```bash
|
||||
API_URL=http://127.0.0.1:8080 /opt/pve-cloud-backup/scripts/integration-test-adguard.sh
|
||||
```
|
||||
|
||||
This queues a backup for guest `adguard` / VMID `110` using storage `hitachi` and dump path `/mnt/pve/hitachi/dump`. Expected archive size is approximately 250 MB. The script is explicitly for manual integration testing and does not hardcode these values into normal app behavior.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
/opt/pve-cloud-backup/scripts/uninstall.sh
|
||||
```
|
||||
|
||||
The uninstall script removes and disables systemd services. It leaves `/opt/pve-cloud-backup` intact so `app.db`, logs, and any remaining data are not destroyed accidentally.
|
||||
@@ -0,0 +1,16 @@
|
||||
# TODO.md
|
||||
|
||||
## TODO
|
||||
|
||||
## In Progress
|
||||
|
||||
|
||||
|
||||
## Completed
|
||||
- Make timezones configureable
|
||||
- show a "in xx:xx:xx" counting down to the next backup a bit more
|
||||
- when deleting a backup that is already deleted, nuke its metadata row entirely
|
||||
- add a better ui for viewing the details of a backup
|
||||
- see the id of a backup job in the editor of them
|
||||
- add backup job counts to the guests list
|
||||
- no "im progress" thing for deleting a backup nor deleting a job and its still hanging on deleting more stuff
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from . import commands
|
||||
from .db import db, row_to_dict, utc_now
|
||||
from .jobs import advance_job, due_jobs
|
||||
from .notifications import notify
|
||||
from .settings_store import get_settings
|
||||
|
||||
|
||||
ACTIVE_STATES = {
|
||||
"queued",
|
||||
"pve_running",
|
||||
"local_ready",
|
||||
"uploading",
|
||||
"remote_ready",
|
||||
"local_deleting",
|
||||
"deleting",
|
||||
}
|
||||
RUNNING_STATES = ACTIVE_STATES - {"queued"}
|
||||
RECOVERABLE_STATES = {
|
||||
"pve_running",
|
||||
"local_ready",
|
||||
"uploading",
|
||||
"remote_ready",
|
||||
"local_deleting",
|
||||
"deleting",
|
||||
}
|
||||
TERMINAL_STATES = {"completed", "failed", "deleted"}
|
||||
|
||||
|
||||
def _active_for_guest(conn, vmid: int) -> bool:
|
||||
row = conn.execute(
|
||||
f"SELECT 1 FROM backups WHERE guest_vmid = ? AND state IN ({','.join('?' for _ in ACTIVE_STATES)}) LIMIT 1",
|
||||
(vmid, *sorted(ACTIVE_STATES)),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _backup_row(backup_id: str) -> dict:
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(f"Backup {backup_id} not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _job_row(job_id: int | None) -> dict | None:
|
||||
if job_id is None:
|
||||
return None
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
def update_backup(backup_id: str, **values) -> dict:
|
||||
values["updated_at"] = utc_now()
|
||||
assignments = ", ".join(f"{key} = ?" for key in values)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE backups SET {assignments} WHERE id = ?",
|
||||
(*values.values(), backup_id),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def fail_backup(backup_id: str, error: str) -> dict:
|
||||
return update_backup(backup_id, state="failed", error_message=error, completed_at=utc_now())
|
||||
|
||||
|
||||
def _claim_queued_backup(backup_id: str) -> dict | None:
|
||||
now = utc_now()
|
||||
with db() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE backups SET state = 'pve_running', updated_at = ? WHERE id = ? AND state = 'queued'",
|
||||
(now, backup_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def queue_backup_for_job(job: dict) -> dict | None:
|
||||
now = utc_now()
|
||||
backup_id = str(uuid.uuid4())
|
||||
settings = get_settings()
|
||||
max_active = max(1, int(settings["max_concurrent_backups"]))
|
||||
with db() as conn:
|
||||
active_count = conn.execute(
|
||||
f"SELECT COUNT(*) AS count FROM backups WHERE state IN ({','.join('?' for _ in ACTIVE_STATES)})",
|
||||
tuple(sorted(ACTIVE_STATES)),
|
||||
).fetchone()["count"]
|
||||
if active_count >= max_active:
|
||||
return None
|
||||
if _active_for_guest(conn, int(job["guest_vmid"])):
|
||||
return None
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, job_id, guest_vmid, guest_name, guest_type, node, state,
|
||||
started_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?)
|
||||
""",
|
||||
(
|
||||
backup_id,
|
||||
job["id"],
|
||||
job["guest_vmid"],
|
||||
job["guest_name"],
|
||||
job["guest_type"],
|
||||
job["node"],
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def list_backups(guest_vmid: int | None = None, state: str | None = None) -> list[dict]:
|
||||
query = "SELECT * FROM backups"
|
||||
args: list = []
|
||||
where = []
|
||||
if guest_vmid is not None:
|
||||
where.append("guest_vmid = ?")
|
||||
args.append(guest_vmid)
|
||||
if state:
|
||||
where.append("state = ?")
|
||||
args.append(state)
|
||||
if where:
|
||||
query += " WHERE " + " AND ".join(where)
|
||||
query += " ORDER BY started_at DESC LIMIT 500"
|
||||
with db() as conn:
|
||||
return [dict(row) for row in conn.execute(query, args).fetchall()]
|
||||
|
||||
|
||||
def get_backup(backup_id: str) -> dict:
|
||||
return _backup_row(backup_id)
|
||||
|
||||
|
||||
def purge_deleted_backup(backup_id: str) -> dict:
|
||||
backup = _backup_row(backup_id)
|
||||
if backup["state"] != "deleted":
|
||||
raise RuntimeError(f"Backup {backup_id} is in state {backup['state']} and is not ready to purge")
|
||||
with db() as conn:
|
||||
conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,))
|
||||
return {"id": backup_id, "purged": True}
|
||||
|
||||
|
||||
def dashboard() -> dict:
|
||||
with db() as conn:
|
||||
latest = row_to_dict(conn.execute("SELECT * FROM backups ORDER BY started_at DESC LIMIT 1").fetchone())
|
||||
active = [dict(row) for row in conn.execute(
|
||||
f"SELECT * FROM backups WHERE state IN ({','.join('?' for _ in ACTIVE_STATES)}) ORDER BY started_at DESC",
|
||||
tuple(sorted(ACTIVE_STATES)),
|
||||
).fetchall()]
|
||||
failed = conn.execute("SELECT COUNT(*) AS count FROM backups WHERE state = 'failed'").fetchone()["count"]
|
||||
remote = conn.execute("SELECT COUNT(*) AS count FROM backups WHERE state = 'completed' AND remote_path IS NOT NULL").fetchone()["count"]
|
||||
next_jobs = [dict(row) for row in conn.execute(
|
||||
"SELECT * FROM backup_jobs WHERE enabled = 1 ORDER BY next_run_at ASC LIMIT 10"
|
||||
).fetchall()]
|
||||
settings = get_settings()
|
||||
local_dir = Path(settings["local_backup_dir"])
|
||||
usage = {"path": str(local_dir), "bytes": 0}
|
||||
if local_dir.exists():
|
||||
usage["bytes"] = sum(p.stat().st_size for p in local_dir.glob("*") if p.is_file())
|
||||
return {
|
||||
"latest_backup": latest,
|
||||
"active_tasks": active,
|
||||
"failed_backups": failed,
|
||||
"remote_backup_count": remote,
|
||||
"local_staging_usage": usage,
|
||||
"next_scheduled_jobs": next_jobs,
|
||||
}
|
||||
|
||||
|
||||
async def _wait_for_pve_task(backup: dict) -> dict:
|
||||
while True:
|
||||
status = await asyncio.to_thread(commands.get_task_status, backup["node"], backup["upid"])
|
||||
if status.get("status") == "stopped":
|
||||
exitstatus = status.get("exitstatus")
|
||||
if exitstatus == "OK":
|
||||
return status
|
||||
raise RuntimeError(f"Proxmox task failed with exitstatus={exitstatus}")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
def _remote_object(settings: dict, backup: dict, archive: Path) -> str:
|
||||
remote = str(settings["rclone_remote"]).rstrip(":")
|
||||
base_path = str(settings.get("rclone_remote_path") or "pve-cloud-backup").strip("/")
|
||||
object_path = f"{backup['id']}/{archive.name}"
|
||||
if base_path:
|
||||
object_path = f"{base_path}/{object_path}"
|
||||
return f"{remote}:{object_path}"
|
||||
|
||||
|
||||
def _remote_parent_dir(remote_object: str) -> str | None:
|
||||
if ":" not in remote_object:
|
||||
return None
|
||||
remote, object_path = remote_object.split(":", 1)
|
||||
object_path = object_path.strip("/")
|
||||
if "/" not in object_path:
|
||||
return None
|
||||
parent_path = object_path.rsplit("/", 1)[0]
|
||||
if not parent_path:
|
||||
return None
|
||||
return f"{remote}:{parent_path}"
|
||||
|
||||
|
||||
async def _remove_empty_remote_parent(settings: dict, remote_object: str) -> None:
|
||||
parent = _remote_parent_dir(remote_object)
|
||||
if not parent:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(commands.rclone_rmdir, settings["rclone_path"], parent)
|
||||
except commands.CommandError:
|
||||
# Non-empty folders and backend-specific "directory not found" behavior
|
||||
# should not turn an already successful exact file delete into a failed
|
||||
# backup deletion.
|
||||
return
|
||||
|
||||
|
||||
async def _delete_remote_file_and_empty_parent(settings: dict, remote_object: str) -> None:
|
||||
await asyncio.to_thread(commands.rclone_delete_file, settings["rclone_path"], remote_object)
|
||||
await _remove_empty_remote_parent(settings, remote_object)
|
||||
|
||||
|
||||
async def process_backup(backup_id: str) -> None:
|
||||
backup = _backup_row(backup_id)
|
||||
settings = get_settings()
|
||||
job = _job_row(backup.get("job_id"))
|
||||
try:
|
||||
if backup["state"] == "queued":
|
||||
await notify("backup_started", backup=backup)
|
||||
backup = _claim_queued_backup(backup_id)
|
||||
if backup is None:
|
||||
return
|
||||
upid = await asyncio.to_thread(
|
||||
commands.start_proxmox_backup,
|
||||
node=backup["node"],
|
||||
vmid=int(backup["guest_vmid"]),
|
||||
storage=(job or {}).get("proxmox_storage") or settings["proxmox_storage"],
|
||||
mode=(job or {}).get("backup_mode") or settings["default_backup_mode"],
|
||||
compression=(job or {}).get("compression") or settings["default_compression"],
|
||||
)
|
||||
backup = update_backup(backup_id, upid=upid)
|
||||
|
||||
if backup["state"] == "pve_running":
|
||||
if not backup.get("upid"):
|
||||
raise RuntimeError("Backup is pve_running but has no Proxmox UPID recorded; refusing to poll task status")
|
||||
await _wait_for_pve_task(backup)
|
||||
backup = update_backup(backup_id, state="local_ready", pve_completed_at=utc_now())
|
||||
await notify("pve_completed", backup=backup)
|
||||
|
||||
if backup["state"] == "local_ready":
|
||||
archive = await asyncio.to_thread(
|
||||
commands.discover_archive,
|
||||
settings["local_backup_dir"],
|
||||
int(backup["guest_vmid"]),
|
||||
backup["guest_type"],
|
||||
backup["started_at"],
|
||||
)
|
||||
size = archive.stat().st_size
|
||||
remote_object = _remote_object(settings, backup, archive)
|
||||
backup = update_backup(
|
||||
backup_id,
|
||||
local_path=str(archive),
|
||||
remote_path=remote_object,
|
||||
size_bytes=size,
|
||||
state="uploading",
|
||||
upload_started_at=utc_now(),
|
||||
)
|
||||
await notify("upload_started", backup=backup)
|
||||
|
||||
if backup["state"] == "uploading":
|
||||
await asyncio.to_thread(
|
||||
commands.rclone_copyto,
|
||||
settings["rclone_path"],
|
||||
backup["local_path"],
|
||||
backup["remote_path"],
|
||||
)
|
||||
exists = await asyncio.to_thread(
|
||||
commands.rclone_object_exists,
|
||||
settings["rclone_path"],
|
||||
backup["remote_path"],
|
||||
)
|
||||
if not exists:
|
||||
raise RuntimeError("Upload finished but remote object was not found")
|
||||
backup = update_backup(backup_id, state="remote_ready")
|
||||
|
||||
if backup["state"] == "remote_ready":
|
||||
backup = update_backup(backup_id, state="local_deleting")
|
||||
|
||||
if backup["state"] == "local_deleting":
|
||||
if backup.get("local_path"):
|
||||
local_path = Path(backup["local_path"])
|
||||
if local_path.exists():
|
||||
local_path.unlink()
|
||||
backup = update_backup(backup_id, state="completed", completed_at=utc_now())
|
||||
await notify("backup_completed", backup=backup)
|
||||
await apply_retention_for_job(backup["job_id"])
|
||||
except Exception as exc:
|
||||
failed = fail_backup(backup_id, str(exc))
|
||||
await notify("backup_failed", backup=failed)
|
||||
|
||||
|
||||
async def delete_remote_backup(backup: dict, reason: str) -> None:
|
||||
if not backup.get("remote_path"):
|
||||
update_backup(backup["id"], state="deleted", deleted_at=utc_now())
|
||||
return
|
||||
settings = get_settings()
|
||||
update_backup(backup["id"], state="deleting")
|
||||
await _delete_remote_file_and_empty_parent(settings, backup["remote_path"])
|
||||
deleted = update_backup(backup["id"], state="deleted", deleted_at=utc_now())
|
||||
await notify("remote_deleted", backup=deleted, message=reason)
|
||||
|
||||
|
||||
async def delete_backup_manually(backup_id: str, reason: str = "manual deletion") -> dict:
|
||||
backup = _backup_row(backup_id)
|
||||
if backup["state"] == "deleted":
|
||||
return purge_deleted_backup(backup_id)
|
||||
if backup["state"] in ACTIVE_STATES:
|
||||
raise RuntimeError(f"Backup {backup_id} is active in state {backup['state']} and cannot be deleted")
|
||||
|
||||
settings = get_settings()
|
||||
if backup.get("remote_path") and backup["state"] != "deleted":
|
||||
exists = await asyncio.to_thread(
|
||||
commands.rclone_object_exists,
|
||||
settings["rclone_path"],
|
||||
backup["remote_path"],
|
||||
)
|
||||
if exists:
|
||||
update_backup(backup_id, state="deleting")
|
||||
await _delete_remote_file_and_empty_parent(settings, backup["remote_path"])
|
||||
else:
|
||||
await _remove_empty_remote_parent(settings, backup["remote_path"])
|
||||
|
||||
if backup.get("local_path"):
|
||||
local_path = Path(backup["local_path"])
|
||||
if local_path.exists() and local_path.is_file():
|
||||
local_path.unlink()
|
||||
|
||||
deleted = update_backup(backup_id, state="deleted", deleted_at=utc_now())
|
||||
await notify("remote_deleted", backup=deleted, message=reason)
|
||||
return deleted
|
||||
|
||||
|
||||
async def delete_job_with_backups(job_id: int) -> dict:
|
||||
with db() as conn:
|
||||
job = row_to_dict(conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone())
|
||||
if not job:
|
||||
raise KeyError(f"Backup job {job_id} not found")
|
||||
active = conn.execute(
|
||||
f"SELECT COUNT(*) AS count FROM backups WHERE job_id = ? AND state IN ({','.join('?' for _ in ACTIVE_STATES)})",
|
||||
(job_id, *sorted(ACTIVE_STATES)),
|
||||
).fetchone()["count"]
|
||||
if active:
|
||||
raise RuntimeError(f"Backup job {job_id} has {active} active backup(s) and cannot be deleted")
|
||||
backups = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT * FROM backups WHERE job_id = ? AND state != 'deleted' ORDER BY started_at ASC",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
deleted_backup_ids: list[str] = []
|
||||
for backup in backups:
|
||||
deleted = await delete_backup_manually(backup["id"], reason=f"job {job_id} deleted")
|
||||
deleted_backup_ids.append(deleted["id"])
|
||||
|
||||
with db() as conn:
|
||||
conn.execute("DELETE FROM backup_jobs WHERE id = ?", (job_id,))
|
||||
return {"job_id": job_id, "deleted_backups": deleted_backup_ids}
|
||||
|
||||
|
||||
async def apply_retention_for_job(job_id: int | None) -> None:
|
||||
if job_id is None:
|
||||
return
|
||||
with db() as conn:
|
||||
job = row_to_dict(conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone())
|
||||
backups = [dict(row) for row in conn.execute(
|
||||
"""
|
||||
SELECT * FROM backups
|
||||
WHERE job_id = ? AND state = 'completed' AND remote_path IS NOT NULL
|
||||
ORDER BY completed_at DESC
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()]
|
||||
if not job:
|
||||
return
|
||||
delete: list[dict] = []
|
||||
if job["retention_type"] == "latest":
|
||||
delete = backups[int(job["retention_value"]):]
|
||||
else:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=int(job["retention_value"]))
|
||||
for backup in backups:
|
||||
completed_at = backup.get("completed_at")
|
||||
if completed_at and datetime.fromisoformat(completed_at) < cutoff:
|
||||
delete.append(backup)
|
||||
for backup in delete:
|
||||
try:
|
||||
await delete_remote_backup(backup, f"retention {job['retention_type']}={job['retention_value']}")
|
||||
except Exception as exc:
|
||||
failed = fail_backup(backup["id"], f"Retention delete failed: {exc}")
|
||||
await notify("backup_failed", backup=failed)
|
||||
|
||||
|
||||
async def retention_sweep() -> None:
|
||||
with db() as conn:
|
||||
job_ids = [
|
||||
row["id"]
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT backup_jobs.id
|
||||
FROM backup_jobs
|
||||
JOIN backups ON backups.job_id = backup_jobs.id
|
||||
WHERE backups.state = 'completed' AND backups.remote_path IS NOT NULL
|
||||
"""
|
||||
).fetchall()
|
||||
]
|
||||
for job_id in job_ids:
|
||||
await apply_retention_for_job(job_id)
|
||||
|
||||
|
||||
async def recover_unfinished() -> None:
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM backups WHERE state IN ({','.join('?' for _ in RECOVERABLE_STATES)})",
|
||||
tuple(sorted(RECOVERABLE_STATES)),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
backup = dict(row)
|
||||
await notify("recovery_action", backup=backup, message=f"resuming state {backup['state']}")
|
||||
if backup["state"] == "deleting":
|
||||
await delete_remote_backup(backup, "recovery delete resume")
|
||||
else:
|
||||
await process_backup(backup["id"])
|
||||
|
||||
|
||||
async def scheduler_tick() -> None:
|
||||
settings = get_settings()
|
||||
limit = max(1, int(settings["max_concurrent_backups"]))
|
||||
with db() as conn:
|
||||
active_count = conn.execute(
|
||||
f"SELECT COUNT(*) AS count FROM backups WHERE state IN ({','.join('?' for _ in ACTIVE_STATES)})",
|
||||
tuple(sorted(ACTIVE_STATES)),
|
||||
).fetchone()["count"]
|
||||
free_slots = max(0, limit - active_count)
|
||||
if free_slots <= 0:
|
||||
return
|
||||
for job in due_jobs(free_slots):
|
||||
queued = queue_backup_for_job(job)
|
||||
advance_job(job)
|
||||
if queued:
|
||||
asyncio.create_task(process_backup(queued["id"]))
|
||||
|
||||
|
||||
async def worker_loop() -> None:
|
||||
await recover_unfinished()
|
||||
retention_sweep_interval_seconds = 3600
|
||||
last_retention_sweep = 0.0
|
||||
while True:
|
||||
await scheduler_tick()
|
||||
now = asyncio.get_running_loop().time()
|
||||
if now - last_retention_sweep >= retention_sweep_interval_seconds:
|
||||
await retention_sweep()
|
||||
last_retention_sweep = now
|
||||
settings = get_settings()
|
||||
max_active = max(1, int(settings["max_concurrent_backups"]))
|
||||
with db() as conn:
|
||||
active_count = conn.execute(
|
||||
f"SELECT COUNT(*) AS count FROM backups WHERE state IN ({','.join('?' for _ in RUNNING_STATES)})",
|
||||
tuple(sorted(RUNNING_STATES)),
|
||||
).fetchone()["count"]
|
||||
free_slots = max(0, max_active - active_count)
|
||||
queued = [dict(row) for row in conn.execute(
|
||||
"SELECT * FROM backups WHERE state = 'queued' ORDER BY started_at ASC LIMIT ?",
|
||||
(free_slots,),
|
||||
).fetchall()]
|
||||
for backup in queued:
|
||||
asyncio.create_task(process_backup(backup["id"]))
|
||||
await asyncio.sleep(30)
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
def __init__(self, command: list[str], result: subprocess.CompletedProcess[str]):
|
||||
super().__init__(
|
||||
f"Command failed ({result.returncode}): {' '.join(command)}\n{result.stderr.strip()}"
|
||||
)
|
||||
self.command = command
|
||||
self.returncode = result.returncode
|
||||
self.stdout = result.stdout
|
||||
self.stderr = result.stderr
|
||||
|
||||
|
||||
def run(command: list[str], timeout: int | None = None) -> CommandResult:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise CommandError(command, result)
|
||||
return CommandResult(stdout=result.stdout, stderr=result.stderr)
|
||||
|
||||
|
||||
def find_executable(name: str) -> str:
|
||||
return shutil.which(name) or ""
|
||||
|
||||
|
||||
def pvesh_json(args: list[str], timeout: int | None = 120):
|
||||
result = run(["pvesh", *args, "--output-format", "json"], timeout=timeout)
|
||||
text = result.stdout.strip()
|
||||
return json.loads(text) if text else None
|
||||
|
||||
|
||||
def _extract_upid(text: str):
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith("UPID:"):
|
||||
return text
|
||||
match = re.search(r"UPID:[^\s\"']+", text)
|
||||
if match:
|
||||
return match.group(0)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if isinstance(data, str) and data.startswith("UPID:"):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for key in ("upid", "data"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.startswith("UPID:"):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def list_guests() -> list[dict]:
|
||||
resources = pvesh_json(["get", "/cluster/resources", "--type", "vm"]) or []
|
||||
guests: list[dict] = []
|
||||
for item in resources:
|
||||
raw_type = item.get("type")
|
||||
guest_type = "lxc" if raw_type == "lxc" else "vm"
|
||||
guests.append(
|
||||
{
|
||||
"vmid": int(item["vmid"]),
|
||||
"name": item.get("name") or str(item["vmid"]),
|
||||
"type": guest_type,
|
||||
"node": item.get("node") or "",
|
||||
"status": item.get("status") or "unknown",
|
||||
}
|
||||
)
|
||||
return sorted(guests, key=lambda row: (row["node"], row["vmid"]))
|
||||
|
||||
|
||||
def list_storages(node: str | None = None) -> list[str]:
|
||||
path = f"/nodes/{node}/storage" if node else "/storage"
|
||||
data = pvesh_json(["get", path]) or []
|
||||
return sorted({item.get("storage") for item in data if item.get("storage")})
|
||||
|
||||
|
||||
def start_proxmox_backup(
|
||||
*,
|
||||
node: str,
|
||||
vmid: int,
|
||||
storage: str,
|
||||
mode: str,
|
||||
compression: str,
|
||||
) -> str:
|
||||
command = [
|
||||
"pvesh",
|
||||
"create",
|
||||
f"/nodes/{node}/vzdump",
|
||||
"--vmid",
|
||||
str(vmid),
|
||||
"--storage",
|
||||
storage,
|
||||
"--mode",
|
||||
mode,
|
||||
"--compress",
|
||||
compression,
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
result = run(command, timeout=300)
|
||||
upid = _extract_upid(result.stdout)
|
||||
if not upid:
|
||||
upid = _extract_upid(result.stderr)
|
||||
if upid:
|
||||
return upid
|
||||
|
||||
# Some pvesh versions ignore --output-format for create actions and return
|
||||
# plain task text. Include both streams so failures are diagnosable without
|
||||
# assuming JSON.
|
||||
raise RuntimeError(
|
||||
"Could not read UPID from Proxmox response: "
|
||||
f"stdout={result.stdout.strip()!r} stderr={result.stderr.strip()!r}"
|
||||
)
|
||||
|
||||
|
||||
def get_task_status(node: str, upid: str) -> dict:
|
||||
return pvesh_json(["get", f"/nodes/{node}/tasks/{upid}/status"]) or {}
|
||||
|
||||
|
||||
def discover_archive(local_backup_dir: str, vmid: int, guest_type: str, started_at: str) -> Path:
|
||||
base = Path(local_backup_dir)
|
||||
prefix = "vzdump-lxc" if guest_type == "lxc" else "vzdump-qemu"
|
||||
archive_suffixes = (
|
||||
".tar",
|
||||
".tar.gz",
|
||||
".tar.lzo",
|
||||
".tar.zst",
|
||||
".vma",
|
||||
".vma.gz",
|
||||
".vma.lzo",
|
||||
".vma.zst",
|
||||
)
|
||||
started = datetime.fromisoformat(started_at)
|
||||
candidates = []
|
||||
for path in base.glob(f"{prefix}-{vmid}-*"):
|
||||
if path.is_file() and path.name.endswith(archive_suffixes):
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=started.tzinfo)
|
||||
if mtime >= started:
|
||||
candidates.append(path)
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"No backup archive found for VMID {vmid} in {base}")
|
||||
return max(candidates, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def rclone_list_remotes(rclone_path: str) -> list[str]:
|
||||
result = run([rclone_path, "listremotes"], timeout=60)
|
||||
return [line.rstrip(":") for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def rclone_remote_type(rclone_path: str, remote_name: str) -> str:
|
||||
remote = remote_name.rstrip(":")
|
||||
result = run([rclone_path, "config", "show", remote])
|
||||
for line in result.stdout.splitlines():
|
||||
if line.strip().startswith("type"):
|
||||
_, value = line.split("=", 1)
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def rclone_copyto(rclone_path: str, local_path: str, remote_object: str) -> None:
|
||||
run([rclone_path, "copyto", local_path, remote_object], timeout=None)
|
||||
|
||||
|
||||
def rclone_object_exists(rclone_path: str, remote_object: str) -> bool:
|
||||
try:
|
||||
run([rclone_path, "lsjson", remote_object], timeout=120)
|
||||
return True
|
||||
except CommandError:
|
||||
return False
|
||||
|
||||
|
||||
def rclone_delete_file(rclone_path: str, remote_object: str) -> None:
|
||||
run([rclone_path, "deletefile", remote_object], timeout=300)
|
||||
|
||||
|
||||
def rclone_rmdir(rclone_path: str, remote_dir: str) -> None:
|
||||
run([rclone_path, "rmdir", remote_dir], timeout=120)
|
||||
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = APP_DIR / "backend"
|
||||
FRONTEND_DIR = APP_DIR / "frontend"
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
DATA_DIR = APP_DIR / "data"
|
||||
LOG_DIR = APP_DIR / "logs"
|
||||
SCRIPTS_DIR = APP_DIR / "scripts"
|
||||
DB_PATH = DATA_DIR / "app.db"
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for path in (STATIC_DIR, DATA_DIR, LOG_DIR, SCRIPTS_DIR):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,37 @@
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterator
|
||||
|
||||
from .config import DB_PATH, ensure_dirs
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
ensure_dirs()
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA busy_timeout = 30000")
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db() -> Iterator[sqlite3.Connection]:
|
||||
conn = connect()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def row_to_dict(row: sqlite3.Row | None) -> dict | None:
|
||||
return None if row is None else dict(row)
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
from .db import db, row_to_dict, utc_now
|
||||
from .settings_store import get_settings
|
||||
|
||||
|
||||
def validate_cron(expr: str) -> None:
|
||||
if not croniter.is_valid(expr):
|
||||
raise ValueError("Invalid cron expression")
|
||||
|
||||
|
||||
def _scheduler_timezone(timezone_name: str | None = None) -> ZoneInfo:
|
||||
return ZoneInfo(timezone_name or get_settings().get("timezone") or "UTC")
|
||||
|
||||
|
||||
def next_run(expr: str, base: datetime | None = None, timezone_name: str | None = None) -> str:
|
||||
base = base or datetime.now(timezone.utc)
|
||||
if base.tzinfo is None:
|
||||
base = base.replace(tzinfo=timezone.utc)
|
||||
scheduler_tz = _scheduler_timezone(timezone_name)
|
||||
local_base = base.astimezone(scheduler_tz)
|
||||
local_next = croniter(expr, local_base).get_next(datetime)
|
||||
if local_next.tzinfo is None:
|
||||
local_next = local_next.replace(tzinfo=scheduler_tz)
|
||||
return local_next.astimezone(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def list_jobs() -> list[dict]:
|
||||
with db() as conn:
|
||||
return [dict(row) for row in conn.execute("SELECT * FROM backup_jobs ORDER BY id DESC")]
|
||||
|
||||
|
||||
def get_job(job_id: int) -> dict:
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(f"Backup job {job_id} not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def create_job(payload: dict) -> dict:
|
||||
validate_cron(payload["cron_schedule"])
|
||||
now = utc_now()
|
||||
with db() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO backup_jobs(
|
||||
guest_vmid, guest_name, guest_type, node, enabled, cron_schedule,
|
||||
proxmox_storage, backup_mode, compression, retention_type, retention_value,
|
||||
next_run_at, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload["guest_vmid"],
|
||||
payload["guest_name"],
|
||||
payload["guest_type"],
|
||||
payload["node"],
|
||||
int(payload.get("enabled", True)),
|
||||
payload["cron_schedule"],
|
||||
payload["proxmox_storage"],
|
||||
payload["backup_mode"],
|
||||
payload["compression"],
|
||||
payload["retention_type"],
|
||||
int(payload["retention_value"]),
|
||||
next_run(payload["cron_schedule"]),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def update_job(job_id: int, payload: dict) -> dict:
|
||||
existing = get_job(job_id)
|
||||
merged = {**existing, **payload}
|
||||
validate_cron(merged["cron_schedule"])
|
||||
merged["next_run_at"] = next_run(merged["cron_schedule"]) if payload.get("cron_schedule") else existing["next_run_at"]
|
||||
merged["updated_at"] = utc_now()
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE backup_jobs SET
|
||||
guest_vmid = ?, guest_name = ?, guest_type = ?, node = ?, enabled = ?,
|
||||
cron_schedule = ?, proxmox_storage = ?, backup_mode = ?, compression = ?,
|
||||
retention_type = ?, retention_value = ?, next_run_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
merged["guest_vmid"],
|
||||
merged["guest_name"],
|
||||
merged["guest_type"],
|
||||
merged["node"],
|
||||
int(merged["enabled"]),
|
||||
merged["cron_schedule"],
|
||||
merged["proxmox_storage"],
|
||||
merged["backup_mode"],
|
||||
merged["compression"],
|
||||
merged["retention_type"],
|
||||
int(merged["retention_value"]),
|
||||
merged["next_run_at"],
|
||||
merged["updated_at"],
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def delete_job(job_id: int) -> None:
|
||||
with db() as conn:
|
||||
conn.execute("DELETE FROM backup_jobs WHERE id = ?", (job_id,))
|
||||
|
||||
|
||||
def recalculate_next_runs() -> None:
|
||||
now = utc_now()
|
||||
with db() as conn:
|
||||
rows = conn.execute("SELECT id, cron_schedule FROM backup_jobs").fetchall()
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
"UPDATE backup_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?",
|
||||
(next_run(row["cron_schedule"]), now, row["id"]),
|
||||
)
|
||||
|
||||
|
||||
def due_jobs(limit: int) -> list[dict]:
|
||||
now = utc_now()
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM backup_jobs
|
||||
WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?
|
||||
ORDER BY next_run_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(now, limit),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def advance_job(job: dict) -> None:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE backup_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?",
|
||||
(next_run(job["cron_schedule"]), utc_now(), job["id"]),
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import commands, jobs
|
||||
from .backup_service import (
|
||||
dashboard,
|
||||
delete_backup_manually,
|
||||
delete_job_with_backups,
|
||||
get_backup,
|
||||
list_backups,
|
||||
process_backup,
|
||||
queue_backup_for_job,
|
||||
)
|
||||
from .config import STATIC_DIR, ensure_dirs
|
||||
from .migrations import run_migrations
|
||||
from .settings_store import get_settings, set_settings
|
||||
|
||||
|
||||
class SettingsPayload(BaseModel):
|
||||
proxmox_node: str = ""
|
||||
proxmox_storage: str = ""
|
||||
local_backup_dir: str = ""
|
||||
rclone_path: str = ""
|
||||
rclone_remote: str = ""
|
||||
rclone_remote_path: str = "pve-cloud-backup"
|
||||
discord_webhook_url: str = ""
|
||||
allowed_cors_origins: list[str] = Field(default_factory=list)
|
||||
default_compression: str = "zstd"
|
||||
default_backup_mode: str = "snapshot"
|
||||
default_retention_type: str = "days"
|
||||
default_retention_value: int = 30
|
||||
max_concurrent_backups: int = 1
|
||||
timezone: str = "UTC"
|
||||
setup_complete: bool = False
|
||||
|
||||
|
||||
class JobPayload(BaseModel):
|
||||
guest_vmid: int
|
||||
guest_name: str
|
||||
guest_type: str
|
||||
node: str
|
||||
enabled: bool = True
|
||||
cron_schedule: str
|
||||
proxmox_storage: str
|
||||
backup_mode: str
|
||||
compression: str
|
||||
retention_type: str
|
||||
retention_value: int
|
||||
|
||||
|
||||
class PartialJobPayload(BaseModel):
|
||||
guest_vmid: int | None = None
|
||||
guest_name: str | None = None
|
||||
guest_type: str | None = None
|
||||
node: str | None = None
|
||||
enabled: bool | None = None
|
||||
cron_schedule: str | None = None
|
||||
proxmox_storage: str | None = None
|
||||
backup_mode: str | None = None
|
||||
compression: str | None = None
|
||||
retention_type: str | None = None
|
||||
retention_value: int | None = None
|
||||
|
||||
|
||||
app = FastAPI(title="PVE Cloud Backup")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def dynamic_cors(request: Request, call_next: Callable):
|
||||
origin = request.headers.get("origin")
|
||||
allowed = get_settings().get("allowed_cors_origins", [])
|
||||
is_allowed = origin and ("*" in allowed or origin in allowed)
|
||||
if request.method == "OPTIONS":
|
||||
response = Response(status_code=204)
|
||||
else:
|
||||
response = await call_next(request)
|
||||
if is_allowed:
|
||||
response.headers["Access-Control-Allow-Origin"] = origin
|
||||
response.headers["Vary"] = "Origin"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization"
|
||||
return response
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
ensure_dirs()
|
||||
run_migrations()
|
||||
|
||||
|
||||
@app.get("/api/setup/status")
|
||||
def setup_status():
|
||||
settings = get_settings()
|
||||
return {"setup_complete": settings["setup_complete"]}
|
||||
|
||||
|
||||
@app.get("/api/discover")
|
||||
def discover():
|
||||
settings = get_settings()
|
||||
rclone_path = settings.get("rclone_path") or commands.find_executable("rclone")
|
||||
remotes: list[str] = []
|
||||
if rclone_path:
|
||||
try:
|
||||
remotes = commands.rclone_list_remotes(rclone_path)
|
||||
except Exception:
|
||||
remotes = []
|
||||
storages: list[str] = []
|
||||
try:
|
||||
storages = commands.list_storages(settings.get("proxmox_node") or None)
|
||||
except Exception:
|
||||
storages = []
|
||||
return {
|
||||
"proxmox_node": settings.get("proxmox_node") or socket.gethostname(),
|
||||
"rclone_path": rclone_path,
|
||||
"rclone_remotes": remotes,
|
||||
"proxmox_storages": storages,
|
||||
}
|
||||
|
||||
|
||||
def _validate_settings(payload: dict) -> None:
|
||||
try:
|
||||
ZoneInfo(payload.get("timezone") or "UTC")
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise HTTPException(400, "Timezone must be a valid IANA timezone, for example Europe/Berlin or UTC") from exc
|
||||
if payload.get("setup_complete"):
|
||||
missing = [
|
||||
key
|
||||
for key in ("proxmox_node", "proxmox_storage", "local_backup_dir", "rclone_path", "rclone_remote")
|
||||
if not payload.get(key)
|
||||
]
|
||||
if missing:
|
||||
raise HTTPException(400, f"Missing required settings: {', '.join(missing)}")
|
||||
try:
|
||||
remotes = commands.rclone_list_remotes(payload["rclone_path"])
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"rclone is not usable: {exc}") from exc
|
||||
if payload["rclone_remote"].rstrip(":") not in remotes:
|
||||
raise HTTPException(400, "Configured rclone remote was not found. Configure rclone first, then rerun setup.")
|
||||
if payload.get("max_concurrent_backups", 1) < 1:
|
||||
raise HTTPException(400, "Maximum concurrent backups must be at least 1")
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
def read_settings():
|
||||
return get_settings()
|
||||
|
||||
|
||||
@app.put("/api/settings")
|
||||
def write_settings(payload: SettingsPayload):
|
||||
data = payload.model_dump()
|
||||
_validate_settings(data)
|
||||
old_timezone = get_settings().get("timezone")
|
||||
try:
|
||||
saved = set_settings(data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
if saved.get("timezone") != old_timezone:
|
||||
jobs.recalculate_next_runs()
|
||||
return get_settings()
|
||||
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
def read_dashboard():
|
||||
return dashboard()
|
||||
|
||||
|
||||
@app.get("/api/guests")
|
||||
def read_guests():
|
||||
try:
|
||||
return commands.list_guests()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Failed to list Proxmox guests via pvesh: {exc}") from exc
|
||||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def read_jobs():
|
||||
return jobs.list_jobs()
|
||||
|
||||
|
||||
@app.post("/api/jobs")
|
||||
def create_job(payload: JobPayload):
|
||||
try:
|
||||
return jobs.create_job(payload.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@app.put("/api/jobs/{job_id}")
|
||||
def update_job(job_id: int, payload: PartialJobPayload):
|
||||
data = {key: value for key, value in payload.model_dump().items() if value is not None}
|
||||
try:
|
||||
return jobs.update_job(job_id, data)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@app.delete("/api/jobs/{job_id}")
|
||||
async def delete_job(job_id: int):
|
||||
try:
|
||||
result = await delete_job_with_backups(job_id)
|
||||
return {"ok": True, **result}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/run")
|
||||
async def run_job_now(job_id: int):
|
||||
try:
|
||||
job = jobs.get_job(job_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
queued = queue_backup_for_job(job)
|
||||
if not queued:
|
||||
raise HTTPException(409, "This guest already has an active backup")
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(process_backup(queued["id"]))
|
||||
return queued
|
||||
|
||||
|
||||
@app.get("/api/backups")
|
||||
def read_backups(guest_vmid: int | None = None, state: str | None = None):
|
||||
return list_backups(guest_vmid=guest_vmid, state=state)
|
||||
|
||||
|
||||
@app.get("/api/backups/{backup_id}")
|
||||
def read_backup(backup_id: str):
|
||||
try:
|
||||
return get_backup(backup_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@app.delete("/api/backups/{backup_id}")
|
||||
async def delete_backup(backup_id: str):
|
||||
try:
|
||||
return await delete_backup_manually(backup_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
|
||||
if (STATIC_DIR / "assets").exists():
|
||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
def spa(full_path: str):
|
||||
index = STATIC_DIR / "index.html"
|
||||
requested = STATIC_DIR / full_path
|
||||
if full_path and requested.exists() and requested.is_file():
|
||||
return FileResponse(requested)
|
||||
if index.exists():
|
||||
return FileResponse(index)
|
||||
return JSONResponse(
|
||||
{"detail": "Frontend has not been built yet. Run /opt/pve-cloud-backup/scripts/install.sh."},
|
||||
status_code=503,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
from pathlib import Path
|
||||
|
||||
from .db import db, utc_now
|
||||
|
||||
|
||||
def _system_timezone() -> str:
|
||||
timezone_file = Path("/etc/timezone")
|
||||
if timezone_file.exists():
|
||||
value = timezone_file.read_text().strip()
|
||||
if value:
|
||||
return value
|
||||
return "UTC"
|
||||
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"setup_complete": "false",
|
||||
"proxmox_node": "",
|
||||
"proxmox_storage": "",
|
||||
"local_backup_dir": "/var/lib/vz/dump",
|
||||
"rclone_path": "/usr/bin/rclone",
|
||||
"rclone_remote": "",
|
||||
"rclone_remote_path": "pve-cloud-backup",
|
||||
"discord_webhook_url": "",
|
||||
"allowed_cors_origins": "http://localhost:5173,http://127.0.0.1:5173",
|
||||
"default_compression": "zstd",
|
||||
"default_backup_mode": "snapshot",
|
||||
"default_retention_type": "days",
|
||||
"default_retention_value": "30",
|
||||
"max_concurrent_backups": "1",
|
||||
"timezone": _system_timezone(),
|
||||
}
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
applied = {
|
||||
row["version"]
|
||||
for row in conn.execute("SELECT version FROM schema_migrations").fetchall()
|
||||
}
|
||||
if 1 not in applied:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE backup_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guest_vmid INTEGER NOT NULL,
|
||||
guest_name TEXT NOT NULL,
|
||||
guest_type TEXT NOT NULL CHECK (guest_type IN ('vm','lxc')),
|
||||
node TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
cron_schedule TEXT NOT NULL,
|
||||
proxmox_storage TEXT NOT NULL,
|
||||
backup_mode TEXT NOT NULL,
|
||||
compression TEXT NOT NULL,
|
||||
retention_type TEXT NOT NULL CHECK (retention_type IN ('days','latest')),
|
||||
retention_value INTEGER NOT NULL,
|
||||
next_run_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id INTEGER,
|
||||
guest_vmid INTEGER NOT NULL,
|
||||
guest_name TEXT NOT NULL,
|
||||
guest_type TEXT NOT NULL,
|
||||
node TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
upid TEXT,
|
||||
local_path TEXT,
|
||||
remote_path TEXT,
|
||||
size_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
pve_completed_at TEXT,
|
||||
upload_started_at TEXT,
|
||||
completed_at TEXT,
|
||||
deleted_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (job_id) REFERENCES backup_jobs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
backup_id TEXT,
|
||||
payload TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (backup_id) REFERENCES backups(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_backup_jobs_enabled_next_run ON backup_jobs(enabled, next_run_at);
|
||||
CREATE INDEX idx_backups_guest_state ON backups(guest_vmid, state);
|
||||
CREATE INDEX idx_backups_state ON backups(state);
|
||||
"""
|
||||
)
|
||||
now = utc_now()
|
||||
conn.executemany(
|
||||
"INSERT INTO settings(key, value, updated_at) VALUES (?, ?, ?)",
|
||||
[(key, value, now) for key, value in DEFAULT_SETTINGS.items()],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
||||
(1, now),
|
||||
)
|
||||
if 2 not in applied:
|
||||
now = utc_now()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO settings(key, value, updated_at)
|
||||
VALUES ('rclone_remote_path', 'pve-cloud-backup', ?)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
""",
|
||||
(now,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
||||
(2, now),
|
||||
)
|
||||
if 3 not in applied:
|
||||
now = utc_now()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO settings(key, value, updated_at)
|
||||
VALUES ('timezone', ?, ?)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
""",
|
||||
(DEFAULT_SETTINGS["timezone"], now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
||||
(3, now),
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .db import db, utc_now
|
||||
from .settings_store import get_setting
|
||||
|
||||
|
||||
COLORS = {
|
||||
"backup_started": 0x3498DB,
|
||||
"pve_completed": 0x2ECC71,
|
||||
"upload_started": 0x9B59B6,
|
||||
"backup_completed": 0x2ECC71,
|
||||
"backup_failed": 0xE74C3C,
|
||||
"remote_deleted": 0xF39C12,
|
||||
"recovery_action": 0x95A5A6,
|
||||
}
|
||||
|
||||
|
||||
def _field(name: str, value: Any) -> dict:
|
||||
return {"name": name, "value": str(value if value is not None else "-"), "inline": True}
|
||||
|
||||
|
||||
def _duration_seconds(backup: dict) -> int | None:
|
||||
started = backup.get("started_at")
|
||||
ended = backup.get("completed_at") or backup.get("deleted_at") or backup.get("pve_completed_at")
|
||||
if not started or not ended:
|
||||
return None
|
||||
try:
|
||||
return int((datetime.fromisoformat(ended) - datetime.fromisoformat(started)).total_seconds())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def notify(event_type: str, *, backup: dict | None = None, message: str = "") -> None:
|
||||
webhook_url = get_setting("discord_webhook_url", "")
|
||||
fields = []
|
||||
backup_id = None
|
||||
if backup:
|
||||
backup_id = backup.get("id")
|
||||
duration = _duration_seconds(backup)
|
||||
enriched = {**backup, "duration_seconds": duration}
|
||||
for key in ("guest_name", "guest_vmid", "id", "state", "size_bytes", "duration_seconds", "error_message"):
|
||||
if enriched.get(key) not in (None, ""):
|
||||
fields.append(_field(key, enriched.get(key)))
|
||||
if message:
|
||||
fields.append(_field("message", message))
|
||||
payload = {
|
||||
"embeds": [
|
||||
{
|
||||
"title": event_type.replace("_", " ").title(),
|
||||
"color": COLORS.get(event_type, 0x3498DB),
|
||||
"timestamp": utc_now(),
|
||||
"fields": fields,
|
||||
}
|
||||
]
|
||||
}
|
||||
success = False
|
||||
error_message = None
|
||||
if webhook_url:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.post(webhook_url, json=payload)
|
||||
response.raise_for_status()
|
||||
success = True
|
||||
except Exception as exc: # Notifications must never fail backups.
|
||||
error_message = str(exc)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO notification_logs(event_type, backup_id, payload, success, error_message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(event_type, backup_id, json.dumps(payload), int(success), error_message, utc_now()),
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .db import db, utc_now
|
||||
from .migrations import DEFAULT_SETTINGS
|
||||
|
||||
|
||||
INT_KEYS = {"default_retention_value", "max_concurrent_backups"}
|
||||
BOOL_KEYS = {"setup_complete"}
|
||||
|
||||
|
||||
def parse_value(key: str, value: str):
|
||||
if key in INT_KEYS:
|
||||
return int(value)
|
||||
if key in BOOL_KEYS:
|
||||
return value.lower() == "true"
|
||||
if key == "allowed_cors_origins":
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return value
|
||||
|
||||
|
||||
def serialize_value(key: str, value) -> str:
|
||||
if key in BOOL_KEYS:
|
||||
return "true" if bool(value) else "false"
|
||||
if key == "allowed_cors_origins":
|
||||
if isinstance(value, list):
|
||||
return ",".join(str(item).strip() for item in value if str(item).strip())
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_settings() -> dict:
|
||||
with db() as conn:
|
||||
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
||||
raw = {row["key"]: row["value"] for row in rows}
|
||||
for key, value in DEFAULT_SETTINGS.items():
|
||||
raw.setdefault(key, value)
|
||||
return {key: parse_value(key, value) for key, value in raw.items()}
|
||||
|
||||
|
||||
def set_settings(values: dict) -> dict:
|
||||
now = utc_now()
|
||||
valid_keys = set(DEFAULT_SETTINGS)
|
||||
unknown = set(values) - valid_keys
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown settings: {', '.join(sorted(unknown))}")
|
||||
with db() as conn:
|
||||
for key, value in values.items():
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO settings(key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, serialize_value(key, value), now),
|
||||
)
|
||||
return get_settings()
|
||||
|
||||
|
||||
def get_setting(key: str, default: str = "") -> str:
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
if row:
|
||||
return row["value"]
|
||||
return default
|
||||
@@ -0,0 +1,21 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .backup_service import worker_loop
|
||||
from .config import LOG_DIR, ensure_dirs
|
||||
from .migrations import run_migrations
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ensure_dirs()
|
||||
logging.basicConfig(
|
||||
filename=LOG_DIR / "worker.log",
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
run_migrations()
|
||||
asyncio.run(worker_loop())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn==0.35.0
|
||||
pydantic==2.11.7
|
||||
croniter==6.0.0
|
||||
httpx==0.28.1
|
||||
pytest==8.4.1
|
||||
@@ -0,0 +1,30 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_db(tmp_path, monkeypatch):
|
||||
from app import db as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "DB_PATH", tmp_path / "app.db")
|
||||
from app.migrations import run_migrations
|
||||
|
||||
run_migrations()
|
||||
return tmp_path / "app.db"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def no_notifications(monkeypatch):
|
||||
async def noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
import app.backup_service as backup_service
|
||||
|
||||
monkeypatch.setattr(backup_service, "notify", noop)
|
||||
@@ -0,0 +1,394 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import db, utc_now
|
||||
from app.jobs import create_job
|
||||
from app.settings_store import set_settings
|
||||
|
||||
|
||||
def _settings(tmp_path):
|
||||
return {
|
||||
"setup_complete": False,
|
||||
"proxmox_node": "pve",
|
||||
"proxmox_storage": "hitachi",
|
||||
"local_backup_dir": str(tmp_path),
|
||||
"rclone_path": "/usr/bin/rclone",
|
||||
"rclone_remote": "onedrive",
|
||||
"rclone_remote_path": "Backups/Proxmox",
|
||||
"discord_webhook_url": "",
|
||||
"allowed_cors_origins": [],
|
||||
"default_compression": "zstd",
|
||||
"default_backup_mode": "snapshot",
|
||||
"default_retention_type": "days",
|
||||
"default_retention_value": 30,
|
||||
"max_concurrent_backups": 1,
|
||||
}
|
||||
|
||||
|
||||
def _job():
|
||||
return create_job(
|
||||
{
|
||||
"guest_vmid": 110,
|
||||
"guest_name": "adguard",
|
||||
"guest_type": "vm",
|
||||
"node": "pve",
|
||||
"enabled": True,
|
||||
"cron_schedule": "0 2 * * *",
|
||||
"proxmox_storage": "hitachi",
|
||||
"backup_mode": "snapshot",
|
||||
"compression": "zstd",
|
||||
"retention_type": "latest",
|
||||
"retention_value": 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_backup_state_transitions_complete_and_delete_local_safely(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
backup = backup_service.queue_backup_for_job(job)
|
||||
archive = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
|
||||
archive.write_bytes(b"backup")
|
||||
|
||||
monkeypatch.setattr(commands, "start_proxmox_backup", lambda **kwargs: "UPID:pve:test")
|
||||
monkeypatch.setattr(commands, "get_task_status", lambda node, upid: {"status": "stopped", "exitstatus": "OK"})
|
||||
monkeypatch.setattr(commands, "discover_archive", lambda *args: archive)
|
||||
monkeypatch.setattr(commands, "rclone_copyto", lambda *args: None)
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
|
||||
monkeypatch.setattr(backup_service, "apply_retention_for_job", lambda job_id: asyncio.sleep(0))
|
||||
|
||||
asyncio.run(backup_service.process_backup(backup["id"]))
|
||||
final = backup_service.get_backup(backup["id"])
|
||||
|
||||
assert final["state"] == "completed"
|
||||
assert final["upid"] == "UPID:pve:test"
|
||||
assert final["remote_path"].startswith("onedrive:Backups/Proxmox/")
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_only_one_worker_can_claim_queued_backup(isolated_db, no_notifications, tmp_path):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
backup = backup_service.queue_backup_for_job(job)
|
||||
|
||||
first = backup_service._claim_queued_backup(backup["id"])
|
||||
second = backup_service._claim_queued_backup(backup["id"])
|
||||
|
||||
assert first is not None
|
||||
assert first["state"] == "pve_running"
|
||||
assert second is None
|
||||
|
||||
|
||||
def test_pve_running_without_upid_fails_without_polling_none(isolated_db, no_notifications, monkeypatch):
|
||||
from app import backup_service, commands
|
||||
|
||||
poll_calls = []
|
||||
monkeypatch.setattr(commands, "get_task_status", lambda *args: poll_calls.append(args))
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
|
||||
VALUES ('missing-upid', 110, 'adguard', 'lxc', 'astrid', 'pve_running', ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.process_backup("missing-upid"))
|
||||
final = backup_service.get_backup("missing-upid")
|
||||
|
||||
assert final["state"] == "failed"
|
||||
assert "no Proxmox UPID" in final["error_message"]
|
||||
assert poll_calls == []
|
||||
|
||||
|
||||
def test_local_archive_is_not_deleted_when_remote_verification_fails(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
backup = backup_service.queue_backup_for_job(job)
|
||||
archive = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
|
||||
archive.write_bytes(b"backup")
|
||||
|
||||
monkeypatch.setattr(commands, "start_proxmox_backup", lambda **kwargs: "UPID:pve:test")
|
||||
monkeypatch.setattr(commands, "get_task_status", lambda node, upid: {"status": "stopped", "exitstatus": "OK"})
|
||||
monkeypatch.setattr(commands, "discover_archive", lambda *args: archive)
|
||||
monkeypatch.setattr(commands, "rclone_copyto", lambda *args: None)
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: False)
|
||||
|
||||
asyncio.run(backup_service.process_backup(backup["id"]))
|
||||
final = backup_service.get_backup(backup["id"])
|
||||
|
||||
assert final["state"] == "failed"
|
||||
assert archive.exists()
|
||||
|
||||
|
||||
def test_retention_latest_selects_expired_remote_backups(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
deleted = []
|
||||
|
||||
async def fake_delete_remote_backup(backup, reason):
|
||||
deleted.append((backup["id"], reason))
|
||||
|
||||
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
|
||||
with db() as conn:
|
||||
for index in range(3):
|
||||
backup_id = f"backup-{index}"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, job_id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, 110, 'adguard', 'vm', 'pve', 'completed', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
backup_id,
|
||||
job["id"],
|
||||
f"crypt:backup/{index}",
|
||||
f"2026-01-01T00:0{index}:00+00:00",
|
||||
f"2026-01-01T00:0{index}:30+00:00",
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.apply_retention_for_job(job["id"]))
|
||||
|
||||
assert deleted == [
|
||||
("backup-1", "retention latest=1"),
|
||||
("backup-0", "retention latest=1"),
|
||||
]
|
||||
|
||||
|
||||
def test_startup_recovery_resumes_known_states(isolated_db, no_notifications, monkeypatch):
|
||||
from app import backup_service
|
||||
|
||||
resumed = []
|
||||
deleted = []
|
||||
|
||||
async def fake_process_backup(backup_id):
|
||||
resumed.append(backup_id)
|
||||
|
||||
async def fake_delete_remote_backup(backup, reason):
|
||||
deleted.append((backup["id"], reason))
|
||||
|
||||
monkeypatch.setattr(backup_service, "process_backup", fake_process_backup)
|
||||
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
|
||||
|
||||
with db() as conn:
|
||||
for backup_id, state in (("b1", "local_ready"), ("b2", "deleting"), ("b3", "completed")):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
|
||||
VALUES (?, 110, 'adguard', 'vm', 'pve', ?, ?, ?)
|
||||
""",
|
||||
(backup_id, state, utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.recover_unfinished())
|
||||
|
||||
assert resumed == ["b1"]
|
||||
assert deleted == [("b2", "recovery delete resume")]
|
||||
|
||||
|
||||
def test_retention_sweep_applies_all_jobs_with_completed_remote_backups(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
swept = []
|
||||
|
||||
async def fake_apply_retention_for_job(job_id):
|
||||
swept.append(job_id)
|
||||
|
||||
monkeypatch.setattr(backup_service, "apply_retention_for_job", fake_apply_retention_for_job)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, job_id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES ('sweep-test', ?, 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
'onedrive:pve-cloud-backup/sweep-test/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(job["id"], utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
asyncio.run(backup_service.retention_sweep())
|
||||
|
||||
assert swept == [job["id"]]
|
||||
|
||||
|
||||
def test_manual_backup_delete_removes_remote_and_known_local_file(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
local_file = tmp_path / "orphaned-local.tar.zst"
|
||||
local_file.write_text("backup")
|
||||
deleted_files = []
|
||||
removed_dirs = []
|
||||
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
|
||||
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: deleted_files.append(args))
|
||||
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, guest_vmid, guest_name, guest_type, node, state,
|
||||
local_path, remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES ('manual-delete', 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
?, 'onedrive:pve-cloud-backup/manual-delete/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(str(local_file), utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
deleted = asyncio.run(backup_service.delete_backup_manually("manual-delete"))
|
||||
|
||||
assert deleted["state"] == "deleted"
|
||||
assert not local_file.exists()
|
||||
assert deleted_files == [
|
||||
(
|
||||
"/usr/bin/rclone",
|
||||
"onedrive:pve-cloud-backup/manual-delete/archive.tar.zst",
|
||||
)
|
||||
]
|
||||
assert removed_dirs == [
|
||||
(
|
||||
"/usr/bin/rclone",
|
||||
"onedrive:pve-cloud-backup/manual-delete",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_manual_backup_delete_removes_empty_remote_folder_when_file_is_already_missing(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service, commands
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
removed_dirs = []
|
||||
|
||||
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: False)
|
||||
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: pytest.fail("deletefile should not run"))
|
||||
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, completed_at, updated_at
|
||||
)
|
||||
VALUES ('missing-remote-file', 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
'onedrive:pve-cloud-backup/missing-remote-file/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
deleted = asyncio.run(backup_service.delete_backup_manually("missing-remote-file"))
|
||||
|
||||
assert deleted["state"] == "deleted"
|
||||
assert removed_dirs == [
|
||||
(
|
||||
"/usr/bin/rclone",
|
||||
"onedrive:pve-cloud-backup/missing-remote-file",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_manual_backup_delete_refuses_active_backup(isolated_db, no_notifications):
|
||||
from app import backup_service
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
|
||||
VALUES ('active-delete', 110, 'adguard', 'vm', 'pve', 'uploading', ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(backup_service.delete_backup_manually("active-delete"))
|
||||
|
||||
|
||||
def test_deleting_already_deleted_backup_purges_metadata_row(isolated_db, no_notifications):
|
||||
from app import backup_service
|
||||
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(
|
||||
id, guest_vmid, guest_name, guest_type, node, state,
|
||||
remote_path, started_at, deleted_at, updated_at
|
||||
)
|
||||
VALUES ('purge-deleted', 110, 'adguard', 'vm', 'pve', 'deleted',
|
||||
'onedrive:pve-cloud-backup/purge-deleted/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
result = asyncio.run(backup_service.delete_backup_manually("purge-deleted"))
|
||||
|
||||
assert result == {"id": "purge-deleted", "purged": True}
|
||||
with db() as conn:
|
||||
assert conn.execute("SELECT COUNT(*) AS count FROM backups WHERE id = 'purge-deleted'").fetchone()["count"] == 0
|
||||
|
||||
|
||||
def test_delete_job_deletes_backups_before_removing_job(
|
||||
isolated_db, no_notifications, tmp_path, monkeypatch
|
||||
):
|
||||
from app import backup_service
|
||||
|
||||
set_settings(_settings(tmp_path))
|
||||
job = _job()
|
||||
deleted = []
|
||||
|
||||
async def fake_delete_backup_manually(backup_id, reason="manual deletion"):
|
||||
deleted.append((backup_id, reason))
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE backups SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?", (utc_now(), utc_now(), backup_id))
|
||||
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
monkeypatch.setattr(backup_service, "delete_backup_manually", fake_delete_backup_manually)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backups(id, job_id, guest_vmid, guest_name, guest_type, node, state, remote_path, started_at, completed_at, updated_at)
|
||||
VALUES ('job-delete-backup', ?, 110, 'adguard', 'vm', 'pve', 'completed',
|
||||
'onedrive:pve-cloud-backup/job-delete-backup/archive.tar.zst', ?, ?, ?)
|
||||
""",
|
||||
(job["id"], utc_now(), utc_now(), utc_now()),
|
||||
)
|
||||
|
||||
result = asyncio.run(backup_service.delete_job_with_backups(job["id"]))
|
||||
|
||||
assert result == {"job_id": job["id"], "deleted_backups": ["job-delete-backup"]}
|
||||
assert deleted == [("job-delete-backup", f"job {job['id']} deleted")]
|
||||
with db() as conn:
|
||||
assert conn.execute("SELECT COUNT(*) AS count FROM backup_jobs WHERE id = ?", (job["id"],)).fetchone()["count"] == 0
|
||||
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import commands
|
||||
|
||||
|
||||
def test_start_proxmox_backup_parses_upid(monkeypatch):
|
||||
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
|
||||
|
||||
def fake_run(command, timeout=None):
|
||||
assert command[:3] == ["pvesh", "create", "/nodes/pve/vzdump"]
|
||||
assert "--output-format" in command
|
||||
return commands.CommandResult(stdout=json.dumps(expected), stderr="")
|
||||
|
||||
monkeypatch.setattr(commands, "run", fake_run)
|
||||
|
||||
assert commands.start_proxmox_backup(
|
||||
node="pve",
|
||||
vmid=110,
|
||||
storage="hitachi",
|
||||
mode="snapshot",
|
||||
compression="zstd",
|
||||
) == expected
|
||||
|
||||
|
||||
def test_start_proxmox_backup_parses_plain_upid(monkeypatch):
|
||||
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
|
||||
|
||||
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=expected, stderr=""))
|
||||
|
||||
assert commands.start_proxmox_backup(
|
||||
node="pve",
|
||||
vmid=110,
|
||||
storage="hitachi",
|
||||
mode="snapshot",
|
||||
compression="zstd",
|
||||
) == expected
|
||||
|
||||
|
||||
def test_start_proxmox_backup_parses_upid_after_info_lines(monkeypatch):
|
||||
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
|
||||
output = f"INFO: starting new backup job\nINFO: Backup job finished successfully\n\"{expected}\""
|
||||
|
||||
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=output, stderr=""))
|
||||
|
||||
assert commands.start_proxmox_backup(
|
||||
node="pve",
|
||||
vmid=110,
|
||||
storage="hitachi",
|
||||
mode="snapshot",
|
||||
compression="zstd",
|
||||
) == expected
|
||||
|
||||
|
||||
def test_list_guests_normalizes_vm_and_lxc(monkeypatch):
|
||||
payload = [
|
||||
{"vmid": "110", "name": "adguard", "type": "qemu", "node": "pve", "status": "running"},
|
||||
{"vmid": "111", "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(commands, "pvesh_json", lambda args: payload)
|
||||
|
||||
guests = commands.list_guests()
|
||||
|
||||
assert guests == [
|
||||
{"vmid": 110, "name": "adguard", "type": "vm", "node": "pve", "status": "running"},
|
||||
{"vmid": 111, "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
|
||||
]
|
||||
|
||||
|
||||
def test_discover_archive_finds_newest_matching_file(tmp_path):
|
||||
old_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
|
||||
new_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.vma.zst"
|
||||
log_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.log"
|
||||
wrong_guest = tmp_path / "vzdump-qemu-999-2026_01_01-00_01_00.vma.zst"
|
||||
for path in (old_file, new_file, log_file, wrong_guest):
|
||||
path.write_text("data")
|
||||
old_file.touch()
|
||||
new_file.touch()
|
||||
log_file.touch()
|
||||
|
||||
found = commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
|
||||
|
||||
assert found == new_file
|
||||
|
||||
|
||||
def test_discover_archive_raises_when_missing(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
|
||||
|
||||
|
||||
def test_rclone_commands_use_exact_objects(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_run(command, timeout=None):
|
||||
calls.append(command)
|
||||
return commands.CommandResult(stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(commands, "run", fake_run)
|
||||
|
||||
commands.rclone_copyto("/usr/bin/rclone", "/tmp/archive.zst", "crypt:backup/archive.zst")
|
||||
commands.rclone_delete_file("/usr/bin/rclone", "crypt:backup/archive.zst")
|
||||
commands.rclone_rmdir("/usr/bin/rclone", "crypt:backup")
|
||||
|
||||
assert calls == [
|
||||
["/usr/bin/rclone", "copyto", "/tmp/archive.zst", "crypt:backup/archive.zst"],
|
||||
["/usr/bin/rclone", "deletefile", "crypt:backup/archive.zst"],
|
||||
["/usr/bin/rclone", "rmdir", "crypt:backup"],
|
||||
]
|
||||
assert all("sync" not in call for call in calls for call in call)
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.jobs import next_run, validate_cron
|
||||
|
||||
|
||||
def test_cron_validation_accepts_standard_expression():
|
||||
validate_cron("0 2 * * *")
|
||||
assert next_run("0 2 * * *")
|
||||
|
||||
|
||||
def test_cron_validation_rejects_invalid_expression():
|
||||
with pytest.raises(ValueError):
|
||||
validate_cron("not a cron")
|
||||
|
||||
|
||||
def test_next_run_interprets_cron_in_configured_timezone():
|
||||
base = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
assert next_run("0 2 * * *", base=base, timezone_name="Europe/Berlin") == "2026-01-01T01:00:00+00:00"
|
||||
@@ -0,0 +1,42 @@
|
||||
from app import commands
|
||||
from app.main import _validate_settings
|
||||
|
||||
|
||||
def test_plain_onedrive_remote_is_accepted(monkeypatch):
|
||||
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
|
||||
|
||||
_validate_settings(
|
||||
{
|
||||
"setup_complete": True,
|
||||
"proxmox_node": "pve",
|
||||
"proxmox_storage": "hitachi",
|
||||
"local_backup_dir": "/mnt/pve/hitachi/dump",
|
||||
"rclone_path": "/usr/bin/rclone",
|
||||
"rclone_remote": "onedrive",
|
||||
"rclone_remote_path": "Backups/Proxmox",
|
||||
"max_concurrent_backups": 1,
|
||||
"timezone": "Europe/Berlin",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_timezone_is_rejected(monkeypatch):
|
||||
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_settings(
|
||||
{
|
||||
"setup_complete": True,
|
||||
"proxmox_node": "pve",
|
||||
"proxmox_storage": "hitachi",
|
||||
"local_backup_dir": "/mnt/pve/hitachi/dump",
|
||||
"rclone_path": "/usr/bin/rclone",
|
||||
"rclone_remote": "onedrive",
|
||||
"rclone_remote_path": "Backups/Proxmox",
|
||||
"max_concurrent_backups": 1,
|
||||
"timezone": "Not/AZone",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PVE Cloud Backup</title>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "pve-cloud-backup-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vue-tsc --noEmit && vite build --outDir ../static --emptyOutDir",
|
||||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.19",
|
||||
"vue-tsc": "^3.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
|
||||
type Settings = {
|
||||
setup_complete: boolean
|
||||
proxmox_node: string
|
||||
proxmox_storage: string
|
||||
local_backup_dir: string
|
||||
rclone_path: string
|
||||
rclone_remote: string
|
||||
rclone_remote_path: string
|
||||
discord_webhook_url: string
|
||||
allowed_cors_origins: string[]
|
||||
default_compression: string
|
||||
default_backup_mode: string
|
||||
default_retention_type: string
|
||||
default_retention_value: number
|
||||
max_concurrent_backups: number
|
||||
timezone: string
|
||||
}
|
||||
|
||||
type Guest = { vmid: number; name: string; type: string; node: string; status: string }
|
||||
type Job = {
|
||||
id: number
|
||||
guest_vmid: number
|
||||
guest_name: string
|
||||
guest_type: string
|
||||
node: string
|
||||
enabled: number | boolean
|
||||
cron_schedule: string
|
||||
proxmox_storage: string
|
||||
backup_mode: string
|
||||
compression: string
|
||||
retention_type: string
|
||||
retention_value: number
|
||||
next_run_at?: string
|
||||
}
|
||||
type Backup = {
|
||||
id: string
|
||||
job_id?: number
|
||||
guest_vmid: number
|
||||
guest_name: string
|
||||
guest_type: string
|
||||
node: string
|
||||
state: string
|
||||
upid?: string
|
||||
local_path?: string
|
||||
remote_path?: string
|
||||
size_bytes?: number
|
||||
error_message?: string
|
||||
started_at: string
|
||||
pve_completed_at?: string
|
||||
upload_started_at?: string
|
||||
completed_at?: string
|
||||
deleted_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
const emptySettings: Settings = {
|
||||
setup_complete: false,
|
||||
proxmox_node: '',
|
||||
proxmox_storage: '',
|
||||
local_backup_dir: '/var/lib/vz/dump',
|
||||
rclone_path: '/usr/bin/rclone',
|
||||
rclone_remote: '',
|
||||
rclone_remote_path: 'pve-cloud-backup',
|
||||
discord_webhook_url: '',
|
||||
allowed_cors_origins: ['http://localhost:5173', 'http://127.0.0.1:5173'],
|
||||
default_compression: 'zstd',
|
||||
default_backup_mode: 'snapshot',
|
||||
default_retention_type: 'days',
|
||||
default_retention_value: 30,
|
||||
max_concurrent_backups: 1,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
}
|
||||
|
||||
const page = ref('Dashboard')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionMessage = ref('')
|
||||
const settings = reactive<Settings>({ ...emptySettings })
|
||||
const corsText = ref('')
|
||||
const discover = reactive({ proxmox_node: '', rclone_path: '', rclone_remotes: [] as string[], proxmox_storages: [] as string[] })
|
||||
const guests = ref<Guest[]>([])
|
||||
const jobs = ref<Job[]>([])
|
||||
const backups = ref<Backup[]>([])
|
||||
const dashboard = ref<any>({})
|
||||
const selectedBackup = ref<Backup | null>(null)
|
||||
const deletingJobId = ref<number | null>(null)
|
||||
const deletingBackupId = ref<string | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
let clockTimer: number | undefined
|
||||
const filters = reactive({ guest_vmid: '', state: '' })
|
||||
const newJob = reactive({
|
||||
guest: '',
|
||||
cron_schedule: '0 2 * * *',
|
||||
enabled: true,
|
||||
retention_type: 'days',
|
||||
retention_value: 30,
|
||||
})
|
||||
const editingJobId = ref<number | null>(null)
|
||||
const editJobForm = reactive({
|
||||
enabled: true,
|
||||
cron_schedule: '',
|
||||
proxmox_storage: '',
|
||||
backup_mode: '',
|
||||
compression: '',
|
||||
retention_type: 'days',
|
||||
retention_value: 1,
|
||||
})
|
||||
|
||||
const pages = computed(() => settings.setup_complete ? ['Dashboard', 'Guests', 'Backup jobs', 'Backup history', 'Backup details', 'Settings'] : ['Setup wizard'])
|
||||
const timezoneOptions = computed(() => {
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
return Array.from(new Set([settings.timezone, browserTimezone, 'Europe/Berlin', 'UTC', 'America/New_York', 'America/Los_Angeles', 'Asia/Tokyo', 'Australia/Sydney'].filter(Boolean)))
|
||||
})
|
||||
const jobCountsByGuest = computed(() => {
|
||||
const counts = new Map<number, { total: number; enabled: number }>()
|
||||
for (const job of jobs.value) {
|
||||
const existing = counts.get(job.guest_vmid) || { total: 0, enabled: 0 }
|
||||
existing.total += 1
|
||||
if (Boolean(job.enabled)) existing.enabled += 1
|
||||
counts.set(job.guest_vmid, existing)
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(body.detail || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function applySettings(data: Settings) {
|
||||
Object.assign(settings, data)
|
||||
corsText.value = data.allowed_cors_origins.join('\n')
|
||||
newJob.retention_type = data.default_retention_type
|
||||
newJob.retention_value = data.default_retention_value
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
applySettings(await api<Settings>('/api/settings'))
|
||||
page.value = settings.setup_complete ? 'Dashboard' : 'Setup wizard'
|
||||
}
|
||||
|
||||
async function loadDiscover() {
|
||||
Object.assign(discover, await api('/api/discover'))
|
||||
if (!settings.proxmox_node) settings.proxmox_node = discover.proxmox_node
|
||||
if (!settings.rclone_path) settings.rclone_path = discover.rclone_path
|
||||
if (!settings.rclone_remote && discover.rclone_remotes.length) settings.rclone_remote = discover.rclone_remotes[0]
|
||||
if (!settings.proxmox_storage && discover.proxmox_storages.length) settings.proxmox_storage = discover.proxmox_storages[0]
|
||||
}
|
||||
|
||||
async function saveSettings(complete = settings.setup_complete) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
actionMessage.value = ''
|
||||
try {
|
||||
const payload = { ...settings, setup_complete: complete, allowed_cors_origins: corsText.value.split(/\n|,/).map(s => s.trim()).filter(Boolean) }
|
||||
applySettings(await api<Settings>('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }))
|
||||
if (settings.setup_complete) page.value = 'Dashboard'
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!settings.setup_complete) return
|
||||
const loaders: Promise<any>[] = []
|
||||
if (page.value === 'Dashboard') loaders.push(api('/api/dashboard').then(v => dashboard.value = v))
|
||||
if (page.value === 'Guests' || page.value === 'Backup jobs') loaders.push(api<Guest[]>('/api/guests').then(v => guests.value = v).catch(e => error.value = e.message))
|
||||
if (page.value === 'Backup jobs' || page.value === 'Dashboard' || page.value === 'Guests') loaders.push(api<Job[]>('/api/jobs').then(v => jobs.value = v))
|
||||
if (page.value === 'Backup history') await loadBackups()
|
||||
await Promise.all(loaders)
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.guest_vmid) params.set('guest_vmid', filters.guest_vmid)
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
backups.value = await api<Backup[]>(`/api/backups?${params}`)
|
||||
}
|
||||
|
||||
async function createJob() {
|
||||
const guest = guests.value.find(g => String(g.vmid) === newJob.guest)
|
||||
if (!guest) {
|
||||
error.value = 'Select a guest'
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
guest_vmid: guest.vmid,
|
||||
guest_name: guest.name,
|
||||
guest_type: guest.type,
|
||||
node: guest.node,
|
||||
enabled: newJob.enabled,
|
||||
cron_schedule: newJob.cron_schedule,
|
||||
proxmox_storage: settings.proxmox_storage,
|
||||
backup_mode: settings.default_backup_mode,
|
||||
compression: settings.default_compression,
|
||||
retention_type: newJob.retention_type,
|
||||
retention_value: Number(newJob.retention_value),
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
actionMessage.value = 'Creating backup job...'
|
||||
try {
|
||||
await api('/api/jobs', { method: 'POST', body: JSON.stringify(payload) })
|
||||
actionMessage.value = ''
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleJob(job: Job) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
actionMessage.value = `${job.enabled ? 'Disabling' : 'Enabling'} job #${job.id}...`
|
||||
try {
|
||||
await api(`/api/jobs/${job.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !Boolean(job.enabled) }) })
|
||||
actionMessage.value = ''
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function beginEditJob(job: Job) {
|
||||
editingJobId.value = job.id
|
||||
editJobForm.enabled = Boolean(job.enabled)
|
||||
editJobForm.cron_schedule = job.cron_schedule
|
||||
editJobForm.proxmox_storage = job.proxmox_storage
|
||||
editJobForm.backup_mode = job.backup_mode
|
||||
editJobForm.compression = job.compression
|
||||
editJobForm.retention_type = job.retention_type
|
||||
editJobForm.retention_value = job.retention_value
|
||||
}
|
||||
|
||||
function cancelEditJob() {
|
||||
editingJobId.value = null
|
||||
}
|
||||
|
||||
async function saveJob(job: Job) {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
actionMessage.value = `Saving job #${job.id}...`
|
||||
try {
|
||||
await api(`/api/jobs/${job.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
enabled: editJobForm.enabled,
|
||||
cron_schedule: editJobForm.cron_schedule,
|
||||
proxmox_storage: editJobForm.proxmox_storage,
|
||||
backup_mode: editJobForm.backup_mode,
|
||||
compression: editJobForm.compression,
|
||||
retention_type: editJobForm.retention_type,
|
||||
retention_value: Number(editJobForm.retention_value),
|
||||
}),
|
||||
})
|
||||
editingJobId.value = null
|
||||
actionMessage.value = ''
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteJob(job: Job) {
|
||||
error.value = ''
|
||||
actionMessage.value = ''
|
||||
const ok = window.confirm(`Delete job for ${job.guest_name} (${job.guest_vmid}) and delete all known remote backups for this job?`)
|
||||
if (!ok) return
|
||||
deletingJobId.value = job.id
|
||||
loading.value = true
|
||||
actionMessage.value = `Deleting job #${job.id} and its known backups. This can take a while if remote files are large...`
|
||||
try {
|
||||
const result = await api<{ deleted_backups: string[] }>(`/api/jobs/${job.id}`, { method: 'DELETE' })
|
||||
editingJobId.value = null
|
||||
actionMessage.value = `Deleted job #${job.id}. Deleted ${result.deleted_backups?.length || 0} backup(s).`
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
deletingJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function runJob(job: Job) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
actionMessage.value = `Queuing job #${job.id}...`
|
||||
try {
|
||||
await api(`/api/jobs/${job.id}/run`, { method: 'POST' })
|
||||
actionMessage.value = ''
|
||||
page.value = 'Dashboard'
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackup(backup: Backup) {
|
||||
error.value = ''
|
||||
actionMessage.value = ''
|
||||
const hardPurge = backup.state === 'deleted'
|
||||
const ok = window.confirm(
|
||||
hardPurge
|
||||
? `Nuke deleted backup ${backup.id} from the app database entirely? The remote/local files are already considered deleted.`
|
||||
: `Delete backup ${backup.id}? If a remote object is recorded, it will be deleted from rclone first.`
|
||||
)
|
||||
if (!ok) return
|
||||
deletingBackupId.value = backup.id
|
||||
loading.value = true
|
||||
actionMessage.value = hardPurge
|
||||
? `Nuking deleted backup ${backup.id} from the app database...`
|
||||
: `Deleting backup ${backup.id}. This can take a while while rclone removes the remote object...`
|
||||
try {
|
||||
const result = await api<Backup | { id: string; purged: boolean }>(`/api/backups/${backup.id}`, { method: 'DELETE' })
|
||||
if ('purged' in result) {
|
||||
selectedBackup.value = null
|
||||
actionMessage.value = `Nuked backup ${backup.id} from the app database.`
|
||||
page.value = 'Backup history'
|
||||
} else {
|
||||
selectedBackup.value = result
|
||||
actionMessage.value = `Deleted backup ${backup.id}.`
|
||||
}
|
||||
await loadBackups()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
deletingBackupId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBytes(value?: number) {
|
||||
if (!value) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let n = value
|
||||
let i = 0
|
||||
while (n > 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
function fmtDate(value?: string) {
|
||||
if (!value) return '—'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
try {
|
||||
return d.toLocaleString(undefined, { timeZone: settings.timezone || 'UTC' })
|
||||
} catch {
|
||||
return d.toLocaleString()
|
||||
}
|
||||
}
|
||||
|
||||
function countdownTo(value?: string) {
|
||||
if (!value) return 'not scheduled'
|
||||
const target = new Date(value).getTime()
|
||||
if (Number.isNaN(target)) return 'invalid time'
|
||||
const diff = target - nowMs.value
|
||||
if (diff <= 0) return 'due now'
|
||||
const totalSeconds = Math.floor(diff / 1000)
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const time = [hours, minutes, seconds].map(v => String(v).padStart(2, '0')).join(':')
|
||||
return days ? `in ${days}d ${time}` : `in ${time}`
|
||||
}
|
||||
|
||||
function valueOrDash(value?: string | number | null) {
|
||||
return value === undefined || value === null || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
function stateClass(state?: string) {
|
||||
if (state === 'completed') return 'border-emerald-800 bg-emerald-950 text-emerald-200'
|
||||
if (state === 'failed') return 'border-red-800 bg-red-950 text-red-200'
|
||||
if (state === 'deleted') return 'border-slate-700 bg-slate-800 text-slate-300'
|
||||
if (state === 'deleting') return 'border-amber-700 bg-amber-950 text-amber-100'
|
||||
return 'border-cyan-800 bg-cyan-950 text-cyan-200'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
clockTimer = window.setInterval(() => {
|
||||
nowMs.value = Date.now()
|
||||
}, 1000)
|
||||
await loadSettings()
|
||||
await loadDiscover()
|
||||
await refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) window.clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<header class="border-b border-slate-800 bg-slate-950/90">
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">PVE Cloud Backup</h1>
|
||||
<p class="text-xs text-slate-400">Proxmox backups to an existing rclone remote</p>
|
||||
</div>
|
||||
<button @click="refresh" :disabled="loading || !settings.setup_complete">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-4">
|
||||
<nav class="mb-4 flex flex-wrap gap-2">
|
||||
<button v-for="p in pages" :key="p" @click="page = p; refresh()" :class="page === p ? 'bg-cyan-500' : 'bg-slate-800 hover:bg-slate-700'">{{ p }}</button>
|
||||
</nav>
|
||||
|
||||
<div v-if="actionMessage" class="mb-4 rounded border border-cyan-800 bg-cyan-950 px-3 py-2 text-sm text-cyan-100">{{ actionMessage }}</div>
|
||||
<div v-if="error" class="mb-4 rounded border border-red-800 bg-red-950 px-3 py-2 text-sm text-red-200">{{ error }}</div>
|
||||
<datalist id="timezones"><option v-for="tz in timezoneOptions" :key="tz" :value="tz" /></datalist>
|
||||
|
||||
<section v-if="page === 'Setup wizard'" class="panel max-w-3xl">
|
||||
<h2 class="mb-3 text-base font-semibold">Initial setup</h2>
|
||||
<p v-if="!discover.rclone_remotes.length" class="mb-3 rounded border border-amber-700 bg-amber-950 p-3 text-sm text-amber-100">
|
||||
No rclone remotes were discovered. Configure rclone separately first. This UI intentionally does not configure rclone.
|
||||
</p>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label><span class="label">Proxmox node</span><input v-model="settings.proxmox_node" class="w-full" /></label>
|
||||
<label><span class="label">Proxmox storage</span><input v-model="settings.proxmox_storage" list="storages" class="w-full" /></label>
|
||||
<datalist id="storages"><option v-for="s in discover.proxmox_storages" :key="s" :value="s" /></datalist>
|
||||
<label><span class="label">Local backup directory</span><input v-model="settings.local_backup_dir" class="w-full" /></label>
|
||||
<label><span class="label">rclone executable</span><input v-model="settings.rclone_path" class="w-full" /></label>
|
||||
<label><span class="label">rclone remote</span><select v-model="settings.rclone_remote" class="w-full"><option value="">Select remote</option><option v-for="r in discover.rclone_remotes" :key="r" :value="r">{{ r }}</option></select></label>
|
||||
<label><span class="label">Remote backup path</span><input v-model="settings.rclone_remote_path" placeholder="pve-cloud-backup" class="w-full" /></label>
|
||||
<label><span class="label">Timezone</span><input v-model="settings.timezone" list="timezones" class="w-full" placeholder="Europe/Berlin" /></label>
|
||||
<label><span class="label">Compression</span><select v-model="settings.default_compression" class="w-full"><option>zstd</option><option>gzip</option><option>lzo</option><option>none</option></select></label>
|
||||
<label><span class="label">Backup mode</span><select v-model="settings.default_backup_mode" class="w-full"><option>snapshot</option><option>suspend</option><option>stop</option></select></label>
|
||||
<label><span class="label">Retention type</span><select v-model="settings.default_retention_type" class="w-full"><option value="days">Keep days</option><option value="latest">Keep latest</option></select></label>
|
||||
<label><span class="label">Retention value</span><input v-model.number="settings.default_retention_value" type="number" min="1" class="w-full" /></label>
|
||||
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
|
||||
<label><span class="label">Discord webhook URL</span><input v-model="settings.discord_webhook_url" class="w-full" /></label>
|
||||
</div>
|
||||
<label class="mt-3 block"><span class="label">Allowed CORS origins, one per line</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button @click="saveSettings(true)" :disabled="loading || !discover.rclone_remotes.length">{{ loading ? 'Saving...' : 'Save setup' }}</button>
|
||||
<button class="bg-slate-700 hover:bg-slate-600" @click="loadDiscover">Rediscover</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="page === 'Dashboard'" class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="panel"><div class="label">Latest backup</div><div class="text-lg">{{ dashboard.latest_backup?.state || 'none' }}</div><div class="text-sm text-slate-400">{{ dashboard.latest_backup?.guest_name }}</div></div>
|
||||
<div class="panel"><div class="label">Active tasks</div><div class="text-lg">{{ dashboard.active_tasks?.length || 0 }}</div></div>
|
||||
<div class="panel"><div class="label">Failed backups</div><div class="text-lg">{{ dashboard.failed_backups || 0 }}</div></div>
|
||||
<div class="panel"><div class="label">Remote backups</div><div class="text-lg">{{ dashboard.remote_backup_count || 0 }}</div></div>
|
||||
<div class="panel"><div class="label">Local staging usage</div><div class="text-lg">{{ fmtBytes(dashboard.local_staging_usage?.bytes) }}</div><div class="text-xs text-slate-500">{{ dashboard.local_staging_usage?.path }}</div></div>
|
||||
<div class="panel lg:col-span-3">
|
||||
<h2 class="mb-2 font-semibold">Next scheduled jobs</h2>
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr v-for="j in dashboard.next_scheduled_jobs || []" :key="j.id" class="border-t border-slate-800">
|
||||
<td class="py-2">{{ j.guest_name }} ({{ j.guest_vmid }})</td>
|
||||
<td>{{ j.cron_schedule }}</td>
|
||||
<td>
|
||||
<div>{{ fmtDate(j.next_run_at) }}</div>
|
||||
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mt-2 text-xs text-slate-500">Schedule timezone: {{ settings.timezone }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="page === 'Guests'" class="panel">
|
||||
<h2 class="mb-2 font-semibold">Guests</h2>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-slate-400"><tr><th>VMID</th><th>Name</th><th>Type</th><th>Node</th><th>Status</th><th>Backup jobs</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="g in guests" :key="g.vmid" class="border-t border-slate-800">
|
||||
<td class="py-2">{{ g.vmid }}</td>
|
||||
<td>{{ g.name }}</td>
|
||||
<td>{{ g.type }}</td>
|
||||
<td>{{ g.node }}</td>
|
||||
<td>{{ g.status }}</td>
|
||||
<td>
|
||||
<span class="rounded border border-slate-700 px-2 py-1 text-xs">
|
||||
{{ jobCountsByGuest.get(g.vmid)?.total || 0 }} total · {{ jobCountsByGuest.get(g.vmid)?.enabled || 0 }} enabled
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-else-if="page === 'Backup jobs'" class="grid gap-4 lg:grid-cols-[1fr_2fr]">
|
||||
<form class="panel" @submit.prevent="createJob">
|
||||
<h2 class="mb-3 font-semibold">Create job</h2>
|
||||
<label class="block mb-2"><span class="label">Guest</span><select v-model="newJob.guest" class="w-full"><option value="">Select</option><option v-for="g in guests" :value="String(g.vmid)" :key="g.vmid">{{ g.name }} ({{ g.vmid }})</option></select></label>
|
||||
<label class="block mb-2"><span class="label">Cron schedule</span><input v-model="newJob.cron_schedule" class="w-full" /></label>
|
||||
<label class="block mb-2"><span class="label">Retention</span><div class="flex gap-2"><select v-model="newJob.retention_type"><option value="days">days</option><option value="latest">latest</option></select><input v-model.number="newJob.retention_value" type="number" min="1" class="w-24" /></div></label>
|
||||
<label class="mb-3 flex items-center gap-2 text-sm"><input type="checkbox" v-model="newJob.enabled" /> Enabled</label>
|
||||
<button :disabled="loading">{{ loading ? 'Creating...' : 'Create' }}</button>
|
||||
</form>
|
||||
<div class="panel">
|
||||
<h2 class="mb-2 font-semibold">Jobs</h2>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-slate-400"><tr><th>Guest</th><th>Cron / settings</th><th>Retention</th><th>Next</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="j in jobs" :key="j.id" class="border-t border-slate-800 align-top">
|
||||
<td class="py-2">{{ j.guest_name }} ({{ j.guest_vmid }})<div class="text-xs text-slate-500">{{ j.guest_type }} on {{ j.node }}</div></td>
|
||||
<template v-if="editingJobId === j.id">
|
||||
<td class="space-y-1 py-2">
|
||||
<div class="rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-300">Editing job #{{ j.id }}</div>
|
||||
<input v-model="editJobForm.cron_schedule" class="w-full" />
|
||||
<div class="grid gap-1 md:grid-cols-3">
|
||||
<input v-model="editJobForm.proxmox_storage" placeholder="storage" />
|
||||
<select v-model="editJobForm.backup_mode"><option>snapshot</option><option>suspend</option><option>stop</option></select>
|
||||
<select v-model="editJobForm.compression"><option>zstd</option><option>gzip</option><option>lzo</option><option>none</option></select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs"><input type="checkbox" v-model="editJobForm.enabled" /> Enabled</label>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<div class="flex gap-1">
|
||||
<select v-model="editJobForm.retention_type"><option value="days">days</option><option value="latest">latest</option></select>
|
||||
<input v-model.number="editJobForm.retention_value" type="number" min="1" class="w-20" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<div>{{ fmtDate(j.next_run_at) }}</div>
|
||||
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
|
||||
</td>
|
||||
<td class="flex flex-wrap gap-2 py-2">
|
||||
<button @click="saveJob(j)" :disabled="loading">{{ loading ? 'Saving...' : 'Save' }}</button>
|
||||
<button class="bg-slate-700" @click="cancelEditJob" :disabled="loading">Cancel</button>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="py-2">{{ j.cron_schedule }}<div class="text-xs text-slate-500">job #{{ j.id }} · {{ j.proxmox_storage }} · {{ j.backup_mode }} · {{ j.compression }} · {{ j.enabled ? 'enabled' : 'disabled' }}</div></td>
|
||||
<td class="py-2">{{ j.retention_type }} {{ j.retention_value }}</td>
|
||||
<td class="py-2">
|
||||
<div>{{ fmtDate(j.next_run_at) }}</div>
|
||||
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
|
||||
</td>
|
||||
<td class="flex flex-wrap gap-2 py-2">
|
||||
<button @click="runJob(j)" :disabled="loading">Run</button>
|
||||
<button class="bg-slate-700" @click="beginEditJob(j)" :disabled="loading">Edit</button>
|
||||
<button class="bg-slate-700" @click="toggleJob(j)" :disabled="loading">{{ j.enabled ? 'Disable' : 'Enable' }}</button>
|
||||
<button class="bg-red-700 hover:bg-red-600" @click="deleteJob(j)" :disabled="loading">
|
||||
{{ deletingJobId === j.id ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="page === 'Backup history'" class="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<div class="panel">
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<input v-model="filters.guest_vmid" placeholder="VMID filter" />
|
||||
<select v-model="filters.state"><option value="">All states</option><option v-for="s in ['queued','pve_running','local_ready','uploading','remote_ready','local_deleting','completed','failed','deleting','deleted']" :key="s">{{ s }}</option></select>
|
||||
<button @click="loadBackups">Filter</button>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-slate-400"><tr><th>Started</th><th>Guest</th><th>State</th><th>Size</th></tr></thead>
|
||||
<tbody><tr v-for="b in backups" :key="b.id" @click="selectedBackup = b; page = 'Backup details'" class="cursor-pointer border-t border-slate-800 hover:bg-slate-800"><td class="py-2">{{ b.started_at }}</td><td>{{ b.guest_name }} ({{ b.guest_vmid }})</td><td>{{ b.state }}</td><td>{{ fmtBytes(b.size_bytes) }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2 class="mb-2 font-semibold">Backup details</h2>
|
||||
<div v-if="selectedBackup" class="space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="stateClass(selectedBackup.state)" class="rounded border px-2 py-1 text-xs">{{ selectedBackup.state }}</span>
|
||||
<span class="text-slate-300">{{ selectedBackup.guest_name }} ({{ selectedBackup.guest_vmid }})</span>
|
||||
</div>
|
||||
<div class="break-all text-xs text-slate-400">{{ selectedBackup.id }}</div>
|
||||
<button class="bg-slate-700" @click="page = 'Backup details'">Open details</button>
|
||||
</div>
|
||||
<p v-else class="text-sm text-slate-400">Select a backup.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="page === 'Backup details'" class="panel">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 class="font-semibold">Backup details</h2>
|
||||
<button v-if="selectedBackup" class="bg-red-700 hover:bg-red-600" @click="deleteBackup(selectedBackup)" :disabled="loading">
|
||||
{{ deletingBackupId === selectedBackup.id ? (selectedBackup.state === 'deleted' ? 'Nuking...' : 'Deleting...') : (selectedBackup.state === 'deleted' ? 'Nuke metadata' : 'Delete backup') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!selectedBackup" class="text-sm text-slate-400">Select a backup from Backup history.</p>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-4">
|
||||
<div class="rounded border border-slate-800 bg-slate-950 p-3">
|
||||
<div class="label">State</div>
|
||||
<span :class="stateClass(selectedBackup.state)" class="mt-1 inline-block rounded border px-2 py-1 text-xs">{{ selectedBackup.state }}</span>
|
||||
</div>
|
||||
<div class="rounded border border-slate-800 bg-slate-950 p-3">
|
||||
<div class="label">Guest</div>
|
||||
<div>{{ selectedBackup.guest_name }} ({{ selectedBackup.guest_vmid }})</div>
|
||||
<div class="text-xs text-slate-500">{{ selectedBackup.guest_type }} on {{ selectedBackup.node }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-slate-800 bg-slate-950 p-3">
|
||||
<div class="label">Size</div>
|
||||
<div>{{ fmtBytes(selectedBackup.size_bytes) }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-slate-800 bg-slate-950 p-3">
|
||||
<div class="label">Job</div>
|
||||
<div>#{{ valueOrDash(selectedBackup.job_id) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded border border-slate-800 bg-slate-950 p-3">
|
||||
<h3 class="mb-2 text-sm font-semibold">Timing</h3>
|
||||
<dl class="grid grid-cols-[8rem_1fr] gap-2 text-sm">
|
||||
<dt class="text-slate-500">Started</dt><dd>{{ fmtDate(selectedBackup.started_at) }}</dd>
|
||||
<dt class="text-slate-500">PVE done</dt><dd>{{ fmtDate(selectedBackup.pve_completed_at) }}</dd>
|
||||
<dt class="text-slate-500">Upload start</dt><dd>{{ fmtDate(selectedBackup.upload_started_at) }}</dd>
|
||||
<dt class="text-slate-500">Completed</dt><dd>{{ fmtDate(selectedBackup.completed_at) }}</dd>
|
||||
<dt class="text-slate-500">Deleted</dt><dd>{{ fmtDate(selectedBackup.deleted_at) }}</dd>
|
||||
<dt class="text-slate-500">Updated</dt><dd>{{ fmtDate(selectedBackup.updated_at) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="rounded border border-slate-800 bg-slate-950 p-3">
|
||||
<h3 class="mb-2 text-sm font-semibold">Identifiers</h3>
|
||||
<dl class="grid grid-cols-[8rem_1fr] gap-2 text-sm">
|
||||
<dt class="text-slate-500">Backup ID</dt><dd class="break-all font-mono text-xs">{{ selectedBackup.id }}</dd>
|
||||
<dt class="text-slate-500">UPID</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.upid) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded border border-slate-800 bg-slate-950 p-3">
|
||||
<h3 class="mb-2 text-sm font-semibold">Storage paths</h3>
|
||||
<dl class="grid gap-2 text-sm md:grid-cols-[8rem_1fr]">
|
||||
<dt class="text-slate-500">Remote</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.remote_path) }}</dd>
|
||||
<dt class="text-slate-500">Local</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.local_path) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedBackup.error_message" class="rounded border border-red-800 bg-red-950 p-3 text-sm text-red-100">
|
||||
<h3 class="mb-2 font-semibold">Error</h3>
|
||||
<div class="whitespace-pre-wrap break-all">{{ selectedBackup.error_message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="page === 'Settings'" class="panel max-w-3xl">
|
||||
<h2 class="mb-3 font-semibold">Settings</h2>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label><span class="label">Proxmox node</span><input v-model="settings.proxmox_node" class="w-full" /></label>
|
||||
<label><span class="label">Proxmox storage</span><input v-model="settings.proxmox_storage" class="w-full" /></label>
|
||||
<label><span class="label">Local backup directory</span><input v-model="settings.local_backup_dir" class="w-full" /></label>
|
||||
<label><span class="label">rclone executable</span><input v-model="settings.rclone_path" class="w-full" /></label>
|
||||
<label><span class="label">rclone remote</span><input v-model="settings.rclone_remote" class="w-full" /></label>
|
||||
<label><span class="label">Remote backup path</span><input v-model="settings.rclone_remote_path" class="w-full" /></label>
|
||||
<label><span class="label">Timezone</span><input v-model="settings.timezone" list="timezones" class="w-full" placeholder="Europe/Berlin" /></label>
|
||||
<label><span class="label">Discord webhook URL</span><input v-model="settings.discord_webhook_url" class="w-full" /></label>
|
||||
<label><span class="label">Compression</span><input v-model="settings.default_compression" class="w-full" /></label>
|
||||
<label><span class="label">Backup mode</span><input v-model="settings.default_backup_mode" class="w-full" /></label>
|
||||
<label><span class="label">Retention type</span><select v-model="settings.default_retention_type" class="w-full"><option value="days">days</option><option value="latest">latest</option></select></label>
|
||||
<label><span class="label">Retention value</span><input v-model.number="settings.default_retention_value" type="number" min="1" class="w-full" /></label>
|
||||
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
|
||||
</div>
|
||||
<label class="mt-3 block"><span class="label">Allowed CORS origins</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
|
||||
<button class="mt-4" @click="saveSettings(true)" :disabled="loading">{{ loading ? 'Saving...' : 'Save settings' }}</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
@apply bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply rounded px-3 py-1.5 text-sm font-medium bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 disabled:hover:bg-cyan-600;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-lg border border-slate-800 bg-slate-900/70 p-4;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-xs uppercase tracking-wide text-slate-400 mb-1;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="/opt/pve-cloud-backup"
|
||||
BACKEND_DIR="$INSTALL_DIR/backend"
|
||||
FRONTEND_DIR="$INSTALL_DIR/frontend"
|
||||
STATIC_DIR="$INSTALL_DIR/static"
|
||||
DATA_DIR="$INSTALL_DIR/data"
|
||||
LOG_DIR="$INSTALL_DIR/logs"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root. The services need pvesh access and systemd installation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$INSTALL_DIR" ]]; then
|
||||
echo "$INSTALL_DIR does not exist. Copy the application files there first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKEND_DIR" "$FRONTEND_DIR" "$STATIC_DIR" "$DATA_DIR" "$LOG_DIR" "$INSTALL_DIR/scripts"
|
||||
|
||||
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; }
|
||||
command -v npm >/dev/null || { echo "npm is required to build the Vue frontend" >&2; exit 1; }
|
||||
|
||||
python3 -m venv "$BACKEND_DIR/.venv"
|
||||
"$BACKEND_DIR/.venv/bin/pip" install --upgrade pip
|
||||
"$BACKEND_DIR/.venv/bin/pip" install -r "$BACKEND_DIR/requirements.txt"
|
||||
|
||||
cd "$FRONTEND_DIR"
|
||||
if [[ -f package-lock.json ]]; then
|
||||
npm ci
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
npm run build
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
"$BACKEND_DIR/.venv/bin/python" -c 'from app.migrations import run_migrations; run_migrations()'
|
||||
|
||||
install -m 0644 "$INSTALL_DIR/systemd/pve-cloud-backup-web.service" /etc/systemd/system/pve-cloud-backup-web.service
|
||||
install -m 0644 "$INSTALL_DIR/systemd/pve-cloud-backup-worker.service" /etc/systemd/system/pve-cloud-backup-worker.service
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now pve-cloud-backup-web.service
|
||||
systemctl enable --now pve-cloud-backup-worker.service
|
||||
|
||||
echo "Installed. Open http://<server>:8080 and complete the setup wizard."
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
API_URL="${API_URL:-http://127.0.0.1:8080}"
|
||||
|
||||
python3 - "$API_URL" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
api = sys.argv[1].rstrip("/")
|
||||
|
||||
def request(method, path, body=None):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
api + path,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as res:
|
||||
return json.loads(res.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SystemExit(f"{method} {path} failed: {exc.code} {exc.read().decode()}") from exc
|
||||
|
||||
settings = request("GET", "/api/settings")
|
||||
if settings.get("proxmox_storage") != "hitachi":
|
||||
raise SystemExit("Expected configured Proxmox storage to be 'hitachi'")
|
||||
if settings.get("local_backup_dir") != "/mnt/pve/hitachi/dump":
|
||||
raise SystemExit("Expected configured local backup directory to be '/mnt/pve/hitachi/dump'")
|
||||
|
||||
guests = request("GET", "/api/guests")
|
||||
guest = next((g for g in guests if int(g["vmid"]) == 110 and g["name"] == "adguard"), None)
|
||||
if not guest:
|
||||
raise SystemExit("Expected Proxmox guest adguard with VMID 110")
|
||||
|
||||
job = request("POST", "/api/jobs", {
|
||||
"guest_vmid": 110,
|
||||
"guest_name": "adguard",
|
||||
"guest_type": guest["type"],
|
||||
"node": guest["node"],
|
||||
"enabled": False,
|
||||
"cron_schedule": "0 3 * * *",
|
||||
"proxmox_storage": "hitachi",
|
||||
"backup_mode": settings["default_backup_mode"],
|
||||
"compression": settings["default_compression"],
|
||||
"retention_type": "days",
|
||||
"retention_value": 1,
|
||||
})
|
||||
backup = request("POST", f"/api/jobs/{job['id']}/run")
|
||||
print(json.dumps({"job": job, "queued_backup": backup}, indent=2))
|
||||
print("Queued integration backup for adguard VMID 110 on storage hitachi (/mnt/pve/hitachi/dump). Expected archive size is approximately 250 MB.")
|
||||
PY
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="/opt/pve-cloud-backup"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl disable --now pve-cloud-backup-worker.service 2>/dev/null || true
|
||||
systemctl disable --now pve-cloud-backup-web.service 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/pve-cloud-backup-worker.service
|
||||
rm -f /etc/systemd/system/pve-cloud-backup-web.service
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "Services removed."
|
||||
echo "Application data remains at $INSTALL_DIR. Remove it manually only after confirming backups and app.db are no longer needed."
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=PVE Cloud Backup FastAPI web application
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/pve-cloud-backup/backend
|
||||
ExecStart=/opt/pve-cloud-backup/backend/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=PVE Cloud Backup worker and scheduler
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/pve-cloud-backup/backend
|
||||
ExecStart=/opt/pve-cloud-backup/backend/.venv/bin/python -m app.worker
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user