Make proxmox_storage a global setting instead of a per-job snapshot
CI / Frontend build (push) Successful in 11s
CI / Script syntax (push) Successful in 4s
CI / Backend tests (push) Successful in 18s

Backup jobs stored their own proxmox_storage column, copied from
settings at creation time and never resynced. process_backup preferred
that frozen job value, so changing Settings > Proxmox storage had no
effect on existing jobs. Drop the per-job column and always use the
current global setting when starting a backup.

Also copies AGENTS.md to CLAUDE.md.
This commit is contained in:
Sonnet 5
2026-07-15 12:20:44 +02:00
parent 608f13cb78
commit 1288ba731d
7 changed files with 239 additions and 17 deletions
+226
View File
@@ -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
<rclone_remote>:<rclone_remote_path>/<backup_id>/<archive_name>
```
- Deleting a backup removes the exact remote file and then attempts to remove the empty `<backup_id>` 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=<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
```
+1 -1
View File
@@ -245,7 +245,7 @@ async def process_backup(backup_id: str) -> None:
commands.start_proxmox_backup, commands.start_proxmox_backup,
node=backup["node"], node=backup["node"],
vmid=int(backup["guest_vmid"]), 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"], mode=(job or {}).get("backup_mode") or settings["default_backup_mode"],
compression=(job or {}).get("compression") or settings["default_compression"], compression=(job or {}).get("compression") or settings["default_compression"],
) )
+3 -5
View File
@@ -51,10 +51,10 @@ def create_job(payload: dict) -> dict:
""" """
INSERT INTO backup_jobs( INSERT INTO backup_jobs(
guest_vmid, guest_name, guest_type, node, enabled, cron_schedule, 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 next_run_at, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
payload["guest_vmid"], payload["guest_vmid"],
@@ -63,7 +63,6 @@ def create_job(payload: dict) -> dict:
payload["node"], payload["node"],
int(payload.get("enabled", True)), int(payload.get("enabled", True)),
payload["cron_schedule"], payload["cron_schedule"],
payload["proxmox_storage"],
payload["backup_mode"], payload["backup_mode"],
payload["compression"], payload["compression"],
payload["retention_type"], payload["retention_type"],
@@ -88,7 +87,7 @@ def update_job(job_id: int, payload: dict) -> dict:
""" """
UPDATE backup_jobs SET UPDATE backup_jobs SET
guest_vmid = ?, guest_name = ?, guest_type = ?, node = ?, enabled = ?, 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 = ? retention_type = ?, retention_value = ?, next_run_at = ?, updated_at = ?
WHERE id = ? WHERE id = ?
""", """,
@@ -99,7 +98,6 @@ def update_job(job_id: int, payload: dict) -> dict:
merged["node"], merged["node"],
int(merged["enabled"]), int(merged["enabled"]),
merged["cron_schedule"], merged["cron_schedule"],
merged["proxmox_storage"],
merged["backup_mode"], merged["backup_mode"],
merged["compression"], merged["compression"],
merged["retention_type"], merged["retention_type"],
-2
View File
@@ -50,7 +50,6 @@ class JobPayload(BaseModel):
node: str node: str
enabled: bool = True enabled: bool = True
cron_schedule: str cron_schedule: str
proxmox_storage: str
backup_mode: str backup_mode: str
compression: str compression: str
retention_type: str retention_type: str
@@ -64,7 +63,6 @@ class PartialJobPayload(BaseModel):
node: str | None = None node: str | None = None
enabled: bool | None = None enabled: bool | None = None
cron_schedule: str | None = None cron_schedule: str | None = None
proxmox_storage: str | None = None
backup_mode: str | None = None backup_mode: str | None = None
compression: str | None = None compression: str | None = None
retention_type: str | None = None retention_type: str | None = None
+7
View File
@@ -179,3 +179,10 @@ def run_migrations() -> None:
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(4, now), (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),
)
-1
View File
@@ -36,7 +36,6 @@ def _job():
"node": "pve", "node": "pve",
"enabled": True, "enabled": True,
"cron_schedule": "0 2 * * *", "cron_schedule": "0 2 * * *",
"proxmox_storage": "backup-store",
"backup_mode": "snapshot", "backup_mode": "snapshot",
"compression": "zstd", "compression": "zstd",
"retention_type": "latest", "retention_type": "latest",
+2 -8
View File
@@ -28,7 +28,6 @@ type Job = {
node: string node: string
enabled: number | boolean enabled: number | boolean
cron_schedule: string cron_schedule: string
proxmox_storage: string
backup_mode: string backup_mode: string
compression: string compression: string
retention_type: string retention_type: string
@@ -102,7 +101,6 @@ const editingJobId = ref<number | null>(null)
const editJobForm = reactive({ const editJobForm = reactive({
enabled: true, enabled: true,
cron_schedule: '', cron_schedule: '',
proxmox_storage: '',
backup_mode: '', backup_mode: '',
compression: '', compression: '',
retention_type: 'days', retention_type: 'days',
@@ -205,7 +203,6 @@ async function createJob() {
node: guest.node, node: guest.node,
enabled: newJob.enabled, enabled: newJob.enabled,
cron_schedule: newJob.cron_schedule, cron_schedule: newJob.cron_schedule,
proxmox_storage: settings.proxmox_storage,
backup_mode: settings.default_backup_mode, backup_mode: settings.default_backup_mode,
compression: settings.default_compression, compression: settings.default_compression,
retention_type: newJob.retention_type, retention_type: newJob.retention_type,
@@ -244,7 +241,6 @@ function beginEditJob(job: Job) {
editingJobId.value = job.id editingJobId.value = job.id
editJobForm.enabled = Boolean(job.enabled) editJobForm.enabled = Boolean(job.enabled)
editJobForm.cron_schedule = job.cron_schedule editJobForm.cron_schedule = job.cron_schedule
editJobForm.proxmox_storage = job.proxmox_storage
editJobForm.backup_mode = job.backup_mode editJobForm.backup_mode = job.backup_mode
editJobForm.compression = job.compression editJobForm.compression = job.compression
editJobForm.retention_type = job.retention_type editJobForm.retention_type = job.retention_type
@@ -265,7 +261,6 @@ async function saveJob(job: Job) {
body: JSON.stringify({ body: JSON.stringify({
enabled: editJobForm.enabled, enabled: editJobForm.enabled,
cron_schedule: editJobForm.cron_schedule, cron_schedule: editJobForm.cron_schedule,
proxmox_storage: editJobForm.proxmox_storage,
backup_mode: editJobForm.backup_mode, backup_mode: editJobForm.backup_mode,
compression: editJobForm.compression, compression: editJobForm.compression,
retention_type: editJobForm.retention_type, retention_type: editJobForm.retention_type,
@@ -528,8 +523,7 @@ onUnmounted(() => {
<td class="space-y-1 py-2"> <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> <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" /> <input v-model="editJobForm.cron_schedule" class="w-full" />
<div class="grid gap-1 md:grid-cols-3"> <div class="grid gap-1 md:grid-cols-2">
<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.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> <select v-model="editJobForm.compression"><option>zstd</option><option>gzip</option><option>lzo</option><option>none</option></select>
</div> </div>
@@ -551,7 +545,7 @@ onUnmounted(() => {
</td> </td>
</template> </template>
<template v-else> <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.cron_schedule }}<div class="text-xs text-slate-500">job #{{ j.id }} · {{ 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">{{ j.retention_type }} {{ j.retention_value }}</td>
<td class="py-2"> <td class="py-2">
<div>{{ fmtDate(j.next_run_at) }}</div> <div>{{ fmtDate(j.next_run_at) }}</div>