diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2ace3c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,226 @@ +# Agent notes for PVE Cloud Backup + +This project is a directly installed Proxmox backup web app. It runs from: + +```text +/opt/pve-cloud-backup/ + backend/ FastAPI app, worker, SQLite migrations, tests + frontend/ Vue 3 + TypeScript + Tailwind source + static/ generated frontend build; ignored by git + data/ runtime SQLite database; ignored by git + logs/ runtime logs; ignored by git + scripts/ install/update/uninstall/manual integration scripts + systemd/ source copies of service units +``` + +The live services are: + +- `pve-cloud-backup-web.service` +- `pve-cloud-backup-worker.service` + +The web app listens on `0.0.0.0:8080`. + +## Core rules + +- Keep runtime configuration in SQLite only. Do not add `.env`, YAML, JSON, TOML, or other runtime config files. +- Do not commit `/opt/pve-cloud-backup/data/`, `/logs/`, `/static/`, `.venv`, `node_modules`, caches, or generated artifacts. +- 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 rclone operations: + - upload with `copyto`; + - verify with `lsjson`; + - delete files with `deletefile`; + - remove empty per-backup folders with `rmdir`. +- 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.sh` and must only run when the user explicitly asks for a real backup test. + +## Runtime data + +SQLite database: + +```text +/opt/pve-cloud-backup/data/app.db +``` + +Important runtime settings live in SQLite, not files: + +- Proxmox node +- Proxmox backup storage +- local dump directory +- rclone executable path +- rclone remote name +- remote path inside that rclone remote +- timezone +- retention defaults +- Discord webhook URL +- CORS origins + +Treat all node names, storage names, guest names, VMIDs, rclone remotes, and filesystem paths as operator-specific runtime data. Do not hardcode deployment-specific values in application code or publish-facing docs. + +## Backend map + +- `backend/app/main.py` + - FastAPI app, API routes, setup/settings validation, static frontend serving. +- `backend/app/backup_service.py` + - Backup state machine, upload/delete/retention/recovery logic. +- `backend/app/commands.py` + - Shell wrappers for `pvesh` and `rclone`. +- `backend/app/jobs.py` + - Cron validation, timezone-aware next-run calculation, job CRUD helpers. +- `backend/app/migrations.py` + - SQLite schema and default settings. +- `backend/app/settings_store.py` + - SQLite settings serialization/parsing. +- `backend/app/worker.py` + - Scheduler/worker entrypoint. +- `backend/tests/` + - Unit tests. Keep external command usage mocked. + +## Frontend map + +- `frontend/src/App.vue` + - Single-file UI for setup, dashboard, guests, jobs, history, backup details, and settings. +- `frontend/src/main.ts` + - Vue mount entrypoint. +- `frontend/src/style.css` + - Tailwind/global styling. + +The built frontend goes to `/opt/pve-cloud-backup/static/` via `npm run build`; do not commit that directory. + +## Backup workflow + +Expected states: + +- `queued` +- `pve_running` +- `local_ready` +- `uploading` +- `remote_ready` +- `local_deleting` +- `completed` +- `failed` +- `deleting` +- `deleted` + +Important behavior: + +- The worker atomically claims queued backups before starting Proxmox work. +- `pve_running` must have a real Proxmox UPID before polling task status. +- Local archive discovery must ignore `.log` and `.notes`; only real archive suffixes count. +- Remote path shape is: + +```text +:// +``` + +- Deleting a backup removes the exact remote file and then attempts to remove the empty `` folder. +- Deleting a backup already in `deleted` state purges the SQLite metadata row. +- Deleting a job deletes all known non-active backups for that job first, then removes the job. +- Retention runs after backup completion and hourly from the worker. + +## Applying updates after pulling changes + +After a `git pull`, apply the new code with: + +```bash +cd /opt/pve-cloud-backup +./scripts/apply-update.sh +``` + +The script: + +1. backs up `data/app.db` to `data/update-backups/`; +2. stops web/worker services; +3. creates/updates `backend/.venv`; +4. installs backend requirements; +5. optionally runs backend tests if `RUN_TESTS=1`; +6. runs `npm ci` or `npm install`; +7. builds frontend assets into `static/`; +8. runs SQLite migrations; +9. installs systemd units from `systemd/`; +10. reloads systemd; +11. restarts and health-checks services. + +Use this stricter variant when practical: + +```bash +cd /opt/pve-cloud-backup +git pull --ff-only +RUN_TESTS=1 ./scripts/apply-update.sh +``` + +If the health check fails, inspect: + +```bash +journalctl -u pve-cloud-backup-web.service -u pve-cloud-backup-worker.service --no-pager -n 100 +``` + +## Validation commands + +CI workflow: + +```text +.gitea/workflows/ci.yml +``` + +The workflow is for Gitea Actions and runs backend tests, frontend build, and shell script syntax checks. It uses setup-action package caching for pip and npm, keyed from `backend/requirements.txt` and `frontend/package-lock.json`. + +Backend tests: + +```bash +cd /opt/pve-cloud-backup/backend +./.venv/bin/pytest +``` + +Frontend typecheck/build: + +```bash +cd /opt/pve-cloud-backup/frontend +npm run build +``` + +Service status: + +```bash +systemctl is-active pve-cloud-backup-web.service pve-cloud-backup-worker.service +``` + +API smoke checks: + +```bash +curl -fsS http://127.0.0.1:8080/api/setup/status +curl -fsS http://127.0.0.1:8080/api/jobs | python3 -m json.tool +curl -fsS http://127.0.0.1:8080/api/backups | python3 -m json.tool +``` + +Manual integration test: + +```bash +INTEGRATION_VMID= API_URL=http://127.0.0.1:8080 /opt/pve-cloud-backup/scripts/integration-test.sh +``` + +Only run the manual integration test when the user explicitly wants a real Proxmox/rclone backup test. + +## Git workflow + +Default branch: + +```text +main +``` + +Do not document private deployment remotes, hostnames, or organization names in committed files. Local git remotes belong in `.git/config`, not project documentation. + +Before committing, check: + +```bash +git status --short --ignored +``` + +Confirm ignored runtime files stay ignored: + +```bash +git check-ignore -v data/app.db logs/worker.log static/index.html frontend/node_modules/.package-lock.json backend/.venv/pyvenv.cfg +``` diff --git a/backend/app/backup_service.py b/backend/app/backup_service.py index 2954620..8c9c666 100644 --- a/backend/app/backup_service.py +++ b/backend/app/backup_service.py @@ -245,7 +245,7 @@ async def process_backup(backup_id: str) -> None: commands.start_proxmox_backup, node=backup["node"], vmid=int(backup["guest_vmid"]), - storage=(job or {}).get("proxmox_storage") or settings["proxmox_storage"], + storage=settings["proxmox_storage"], mode=(job or {}).get("backup_mode") or settings["default_backup_mode"], compression=(job or {}).get("compression") or settings["default_compression"], ) diff --git a/backend/app/jobs.py b/backend/app/jobs.py index 152e183..2983077 100644 --- a/backend/app/jobs.py +++ b/backend/app/jobs.py @@ -51,10 +51,10 @@ def create_job(payload: dict) -> dict: """ INSERT INTO backup_jobs( guest_vmid, guest_name, guest_type, node, enabled, cron_schedule, - proxmox_storage, backup_mode, compression, retention_type, retention_value, + backup_mode, compression, retention_type, retention_value, next_run_at, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( payload["guest_vmid"], @@ -63,7 +63,6 @@ def create_job(payload: dict) -> dict: payload["node"], int(payload.get("enabled", True)), payload["cron_schedule"], - payload["proxmox_storage"], payload["backup_mode"], payload["compression"], payload["retention_type"], @@ -88,7 +87,7 @@ def update_job(job_id: int, payload: dict) -> dict: """ UPDATE backup_jobs SET guest_vmid = ?, guest_name = ?, guest_type = ?, node = ?, enabled = ?, - cron_schedule = ?, proxmox_storage = ?, backup_mode = ?, compression = ?, + cron_schedule = ?, backup_mode = ?, compression = ?, retention_type = ?, retention_value = ?, next_run_at = ?, updated_at = ? WHERE id = ? """, @@ -99,7 +98,6 @@ def update_job(job_id: int, payload: dict) -> dict: merged["node"], int(merged["enabled"]), merged["cron_schedule"], - merged["proxmox_storage"], merged["backup_mode"], merged["compression"], merged["retention_type"], diff --git a/backend/app/main.py b/backend/app/main.py index 1e7c214..66ec8bb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,7 +50,6 @@ class JobPayload(BaseModel): node: str enabled: bool = True cron_schedule: str - proxmox_storage: str backup_mode: str compression: str retention_type: str @@ -64,7 +63,6 @@ class PartialJobPayload(BaseModel): 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 diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 28cc94c..0e5b32b 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -179,3 +179,10 @@ def run_migrations() -> None: "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", (4, now), ) + if 5 not in applied: + now = utc_now() + conn.execute("ALTER TABLE backup_jobs DROP COLUMN proxmox_storage") + conn.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", + (5, now), + ) diff --git a/backend/tests/test_backup_service.py b/backend/tests/test_backup_service.py index 6bd2e08..cae1e83 100644 --- a/backend/tests/test_backup_service.py +++ b/backend/tests/test_backup_service.py @@ -36,7 +36,6 @@ def _job(): "node": "pve", "enabled": True, "cron_schedule": "0 2 * * *", - "proxmox_storage": "backup-store", "backup_mode": "snapshot", "compression": "zstd", "retention_type": "latest", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3190eec..6184594 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -28,7 +28,6 @@ type Job = { node: string enabled: number | boolean cron_schedule: string - proxmox_storage: string backup_mode: string compression: string retention_type: string @@ -102,7 +101,6 @@ const editingJobId = ref(null) const editJobForm = reactive({ enabled: true, cron_schedule: '', - proxmox_storage: '', backup_mode: '', compression: '', retention_type: 'days', @@ -205,7 +203,6 @@ async function createJob() { 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, @@ -244,7 +241,6 @@ 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 @@ -265,7 +261,6 @@ async function saveJob(job: Job) { 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, @@ -528,8 +523,7 @@ onUnmounted(() => {
Editing job #{{ j.id }}
-
- +
@@ -551,7 +545,7 @@ onUnmounted(() => {