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,
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"],
)
+3 -5
View File
@@ -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"],
-2
View File
@@ -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
+7
View File
@@ -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),
)
-1
View File
@@ -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",
+2 -8
View File
@@ -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<number | null>(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(() => {
<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" />
<div class="grid gap-1 md:grid-cols-2">
<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>
@@ -551,7 +545,7 @@ onUnmounted(() => {
</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.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">
<div>{{ fmtDate(j.next_run_at) }}</div>