Skip to content

Commit 1e9a9c1

Browse files
committed
v1.6.7: multi-destination fixes + backup compare/history UI
Fixes two backup-path bugs and adds the revision-browsing UI that was implicitly requested by the "feat: local git repo" issue (local git destinations already work; what was missing was a way to browse and compare revisions in the web UI). Bug fixes - Manual backup trigger now honors the destination selection. /backups/trigger previously dropped destination_ids on the floor and fell back to "the first enabled local destination", so picking SMB + local from the UI silently saved only to local. POST handler now accepts destination_ids and the form renders a destination checkbox group. - Backup row now records every destination that actually received the backup, not only the last. The text handler used to overwrite destination_type/destination_path inside its save loop, so a backup written to both local and SMB would only show one in the UI. A new _summarize_results helper aggregates successful (dest_type, path) tuples; destination_type is set to a comma-joined label ("local, smb") and destination_path keeps the local copy when one exists so the in-app Download / Delete buttons stay pointed at the local file. - Proxmox / binary-archive backups now ship to every selected destination, not only local. _handle_binary_backup used to call _save_binary_local unconditionally and hardcode destination_type = "local". Added an optional save_binary(hostname, data, extension, config) method on DestinationBackend (default raises NotImplementedError so git-style backends can be cleanly skipped); LocalDestination and SMBDestination implement it. The binary handler now loops destinations, records all that accepted the archive, and only marks the backup failed when none did. New UI - Per-device backup history at /backups/device/<id>/history — full timeline with First / Changed / Unchanged badges computed from config_hash (no difflib per row) and checkboxes to pick any two revisions to compare. - Compare view at /backups/compare?a=<id>&b=<id> — unified diff between any two backups of the same device. Normalises older→newer automatically, handles identical configs, archive bundles, and missing content with distinct banners. - Destination badges: new partials/destination_badges.html renders colored Bootstrap badges per destination type (local gray, smb teal, git/gitea/github/forgejo blue); wired into list / detail / history. - Backup detail gains "History" and "Compare with previous" buttons; device detail's Backup History card gains a "View all / Compare" link. Housekeeping - .dockerignore added to keep .env, *.db, ssh_keys/, backups/, staging-backups/, venv/, .git/ out of the Docker build context (previous builds baked all of these into the image). - .gitignore adds staging-backups/* !staging-backups/.gitkeep so test artifacts generated during local runs don't show up as untracked. Verification - tests/: 50 passed, 1 skipped (baseline: same on upstream 1.6.6) - Unit smoke: multi-dest text save records "git, local, smb"; binary-to-SMB calls save_binary on local+smb, skips git with NotImplementedError warning; binary-only-git fails cleanly with "could not be written to any destination". - HTTP smoke: history + compare routes render correctly, list + detail + history pages show destination badges, trigger form renders the destination picker. - Upgrade smoke: running upstream _apply_migrations against the old staging.db (alembic_version=d4e5f6g7h8i9, credentials.username NOT NULL) correctly ran v09/v15/v16/v161/v161_repair and preserved all 3 credentials / 3 devices / 98 backups.
1 parent ce0625a commit 1e9a9c1

18 files changed

Lines changed: 621 additions & 116 deletions

File tree

.dockerignore

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Secrets and local config — never bake into image
2+
.env
3+
.env.*
4+
!.env.example
5+
6+
# Local databases
7+
*.db
8+
*.db-journal
9+
*.db-wal
10+
*.db-shm
11+
12+
# Local backups and key material
13+
backups/
14+
staging-backups/
15+
ssh_keys/
16+
17+
# Virtual environments
18+
venv/
19+
.venv/
20+
21+
# Git + CI metadata
22+
.git/
23+
.github/
24+
.gitignore
25+
26+
# Python build artifacts
27+
__pycache__/
28+
*.pyc
29+
*.pyo
30+
*.egg-info/
31+
dist/
32+
build/
33+
.pytest_cache/
34+
35+
# Editor / tooling
36+
.codex
37+
.vscode/
38+
.idea/
39+
*.swp
40+
41+
# Tests and developer scripts (keep image lean)
42+
tests/
43+
docs/
44+
staging.db
45+
vibenetbackup.db

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ __pycache__/
55
*.db
66
backups/*
77
!backups/.gitkeep
8+
staging-backups/*
9+
!staging-backups/.gitkeep
810
ssh_keys/*
911
.venv/
1012
*.egg-info/

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Network device configuration backup manager with multi-engine support, automated scheduling, and retention policies.
44

5-
**Version:** 1.5.7 | **License:** MIT
5+
**Version:** 1.6.7 | **License:** MIT
66

77
<p align="center">
88
<img src="docs/screenshots/screensh_01.png" alt="VIBENetBackup Dashboard" width="900"/>
@@ -109,6 +109,14 @@ Open `http://<your-server-ip>:5005` — default credentials are shown during ins
109109

110110
## Changelog
111111

112+
### v1.6.7 (2026-04-23)
113+
- **Per-device backup history** — new timeline view at `/backups/device/<id>/history` with "First / Changed / Unchanged" markers computed from config hash, and checkboxes to pick any two revisions to compare
114+
- **Diff-any-two-backups view** — new compare page at `/backups/compare?a=<id>&b=<id>` renders a unified diff between arbitrary backups of the same device; handles identical-config, archive-bundle, and a/b-order-swap cases
115+
- **Manual backup now honors destination selection** — the trigger form gained a destination checkbox group; the POST handler accepts `destination_ids` so manual runs write to the destinations you pick instead of falling back to "first local"
116+
- **Multi-destination recording** — when a backup writes to multiple destinations (e.g. local + SMB), the `Backup` row now records all of them (`destination_type` = `"local, smb"`), displayed as colored badges in the list / detail / history views. Previously only the last successful destination showed
117+
- **Proxmox → SMB / remote destinations** — binary/archive backups (Proxmox tarballs) now ship to every selected destination, not only local. New optional `save_binary(hostname, data, extension, config)` method on `DestinationBackend`; `LocalDestination` and `SMBDestination` implement it; git-family destinations are skipped with a warning (archives in git are unusual)
118+
- **`.dockerignore`** — added to keep `.env`, `staging*.db`, `ssh_keys/`, `backups/`, `venv/`, `.git/`, and test caches out of the Docker build context
119+
112120
### v1.5.7 (2026-04-10)
113121
- **SSH Proxy / Jump Host** — Netmiko and SCP engines can now connect through a bastion/jump host before reaching the target device. Useful for remote sites where devices are only reachable via an intermediate SSH server (e.g. autossh tunnels). Configure per device: proxy host, proxy port, and optionally a separate proxy credential when the jump host uses different credentials than the device
114122
- **Separate proxy credentials** — Jump host and target device can authenticate with different username/password pairs, reusing the existing encrypted credential store

app/modules/backup_service.py

Lines changed: 102 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,35 @@ async def run_backup_for_device(
9090
)
9191

9292

93+
def _resolve_destinations(db: Session, destination_ids: list[int] | None) -> list[Destination]:
94+
"""Enabled Destination rows for the given IDs, or all enabled local
95+
destinations as a fallback when nothing was selected."""
96+
destinations: list[Destination] = []
97+
if destination_ids:
98+
destinations = db.query(Destination).filter(
99+
Destination.id.in_(destination_ids),
100+
Destination.enabled == True,
101+
).all()
102+
if not destinations:
103+
destinations = db.query(Destination).filter(
104+
Destination.dest_type == "local",
105+
Destination.enabled == True,
106+
).all()
107+
return destinations
108+
109+
110+
def _summarize_results(results: list[tuple[str, str]]) -> tuple[str, str | None]:
111+
"""Pick a comma-joined label and a primary path for a successful multi-dest save.
112+
Prefers a local path for `destination_path` so in-app Download/Delete keeps
113+
pointing at the local copy."""
114+
if not results:
115+
return "local", None
116+
types = sorted({t for t, _ in results})
117+
local_paths = [p for t, p in results if t == "local"]
118+
primary = local_paths[0] if local_paths else results[0][1]
119+
return ", ".join(types), primary
120+
121+
93122
async def _handle_binary_backup(
94123
db: Session,
95124
device: Device,
@@ -98,145 +127,107 @@ async def _handle_binary_backup(
98127
destination_ids: list[int] | None,
99128
job_run,
100129
) -> Backup:
101-
"""Save a binary (tar.gz/zip) backup to disk and store a JSON manifest in the DB."""
130+
"""Save a binary (tar.gz/zip) backup to each selected destination and
131+
store a JSON manifest in the DB."""
102132
file_bytes, extension, file_list = binary_result
103-
104-
# Determine archive type from extension
105133
archive_type = "tgz" if extension == ".tar.gz" else "zip"
106-
107134
config_hash = hashlib.sha256(file_bytes).hexdigest()
108135

109-
# Save file to local destination
110-
saved_path = await _save_binary_local(device, file_bytes, extension, destination_ids, db)
136+
destinations = _resolve_destinations(db, destination_ids)
111137

138+
results: list[tuple[str, str]] = []
139+
save_errors: list[str] = []
140+
for dest in destinations:
141+
dest_type = dest.dest_type.value
142+
try:
143+
backend = get_destination(dest_type)
144+
path = await backend.save_binary(
145+
hostname=device.hostname,
146+
data=file_bytes,
147+
extension=extension,
148+
config=dest.config_json or {},
149+
)
150+
results.append((dest_type, path))
151+
logger.info(
152+
"Saved binary backup for %s to %s: %s",
153+
device.hostname, dest_type, path,
154+
)
155+
except NotImplementedError:
156+
logger.warning(
157+
"Destination '%s' does not support archive backups — skipping for %s",
158+
dest_type, device.hostname,
159+
)
160+
except Exception as e:
161+
logger.error(
162+
"Failed to save binary backup to %s for %s: %s",
163+
dest_type, device.hostname, e,
164+
)
165+
save_errors.append(f"{dest_type}: {e}")
166+
167+
if not results:
168+
backup.config_hash = config_hash
169+
backup.file_size = len(file_bytes)
170+
backup.status = BackupStatus.failed
171+
backup.error_message = (
172+
"Binary backup could not be written to any destination"
173+
+ (": " + "; ".join(save_errors) if save_errors else "")
174+
)
175+
db.commit()
176+
logger.error("Binary backup for %s failed — no destination accepted the archive", device.hostname)
177+
return backup
178+
179+
dest_label, primary_path = _summarize_results(results)
112180
manifest = json.dumps({
113181
"type": archive_type,
114-
"path": saved_path,
182+
"path": primary_path,
115183
"files": sorted(file_list),
116184
"file_count": len(file_list),
117185
})
118186

119187
backup.config_text = manifest
120188
backup.config_hash = config_hash
121189
backup.file_size = len(file_bytes)
122-
backup.destination_type = "local"
123-
backup.destination_path = saved_path
190+
backup.destination_type = dest_label
191+
backup.destination_path = primary_path
124192
backup.status = BackupStatus.success
125193
db.commit()
126194

127195
logger.info(
128-
"Binary backup complete for %s: %d files, %d bytes, hash=%s...",
129-
device.hostname, len(file_list), len(file_bytes), config_hash[:12],
196+
"Binary backup complete for %s: %d files, %d bytes, hash=%s..., destinations=%s",
197+
device.hostname, len(file_list), len(file_bytes), config_hash[:12], dest_label,
130198
)
131199
return backup
132200

133201

134-
async def _save_binary_local(
135-
device: Device,
136-
file_bytes: bytes,
137-
extension: str,
138-
destination_ids: list[int] | None,
139-
db: Session,
140-
) -> str:
141-
"""Save binary file to the local backup directory, return saved path."""
142-
from app.config import get_settings
143-
import os
144-
145-
# Resolve base dir from a configured LOCAL destination only.
146-
# Binary (archive) backups are currently local-only; non-local destinations
147-
# (SMB, Git) are not supported for binary payloads and are skipped with a warning.
148-
base_dir = get_settings().BACKUP_DIR
149-
if destination_ids:
150-
dests = db.query(Destination).filter(
151-
Destination.id.in_(destination_ids),
152-
Destination.enabled == True,
153-
).all()
154-
local_dest = next((d for d in dests if d.dest_type.value == "local"), None)
155-
non_local = [d for d in dests if d.dest_type.value != "local"]
156-
if non_local:
157-
names = ", ".join(d.dest_type.value for d in non_local)
158-
logger.warning(
159-
"Binary/archive backups do not yet support non-local destinations (%s) "
160-
"for %s — saving locally only.",
161-
names, device.hostname,
162-
)
163-
if local_dest and local_dest.config_json:
164-
base_dir = local_dest.config_json.get("path", base_dir)
165-
166-
safe_hostname = os.path.basename(device.hostname.replace("\\", "/")) or "unknown"
167-
device_dir = os.path.join(base_dir, safe_hostname)
168-
if not os.path.realpath(device_dir).startswith(os.path.realpath(base_dir)):
169-
raise ValueError(f"Invalid hostname for path: {device.hostname}")
170-
os.makedirs(device_dir, exist_ok=True)
171-
172-
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
173-
filename = f"{timestamp}{extension}"
174-
filepath = os.path.join(device_dir, filename)
175-
176-
import asyncio
177-
await asyncio.to_thread(_write_binary, filepath, file_bytes)
178-
179-
# Update latest symlink
180-
latest = os.path.join(device_dir, f"latest{extension}")
181-
if os.path.islink(latest):
182-
os.unlink(latest)
183-
try:
184-
os.symlink(filepath, latest)
185-
except OSError:
186-
pass
187-
188-
logger.info("Saved binary backup to %s (%d bytes)", filepath, len(file_bytes))
189-
return filepath
190-
191-
192-
def _write_binary(path: str, content: bytes) -> None:
193-
with open(path, "wb") as f:
194-
f.write(content)
195-
196-
197202
async def _handle_text_backup(
198203
db: Session,
199204
device: Device,
200205
backup: Backup,
201206
config_text: str,
202207
destination_ids: list[int] | None,
203208
) -> Backup:
204-
"""Save a text config backup (existing flow)."""
209+
"""Save a text config backup to each selected destination."""
205210
config_hash = hashlib.sha256(config_text.encode()).hexdigest()
211+
destinations = _resolve_destinations(db, destination_ids)
206212

207-
destinations = []
208-
if destination_ids:
209-
destinations = db.query(Destination).filter(
210-
Destination.id.in_(destination_ids),
211-
Destination.enabled == True,
212-
).all()
213-
214-
if not destinations:
215-
destinations = db.query(Destination).filter(
216-
Destination.dest_type == "local",
217-
Destination.enabled == True,
218-
).all()
219-
220-
saved_path = None
221-
dest_type = "local"
213+
results: list[tuple[str, str]] = []
222214
save_errors: list[str] = []
223-
224215
for dest in destinations:
216+
dest_type = dest.dest_type.value
225217
try:
226-
backend = get_destination(dest.dest_type.value)
227-
saved_path = await backend.save(
218+
backend = get_destination(dest_type)
219+
path = await backend.save(
228220
hostname=device.hostname,
229221
config_text=config_text,
230222
config=dest.config_json or {},
231223
)
232-
dest_type = dest.dest_type.value
233-
logger.info("Saved backup for %s to %s: %s", device.hostname, dest_type, saved_path)
224+
results.append((dest_type, path))
225+
logger.info("Saved backup for %s to %s: %s", device.hostname, dest_type, path)
234226
except Exception as e:
235-
logger.error("Failed to save to %s for %s: %s", dest.dest_type.value, device.hostname, e)
236-
save_errors.append(f"{dest.dest_type.value}: {e}")
227+
logger.error("Failed to save to %s for %s: %s", dest_type, device.hostname, e)
228+
save_errors.append(f"{dest_type}: {e}")
237229

238-
if destinations and saved_path is None:
239-
# Every configured destination failed — do not report success
230+
if destinations and not results:
240231
backup.config_text = config_text
241232
backup.config_hash = config_hash
242233
backup.file_size = len(config_text)
@@ -246,24 +237,31 @@ async def _handle_text_backup(
246237
logger.error("Backup for %s failed — no destination saved successfully", device.hostname)
247238
return backup
248239

249-
if not destinations:
240+
if not results:
241+
# No destinations configured at all — last-resort local save so existing
242+
# deployments without any enabled destination still produce a file.
250243
from app.modules.destinations.local import LocalDestination
251244
local = LocalDestination()
252-
saved_path = await local.save(
245+
path = await local.save(
253246
hostname=device.hostname,
254247
config_text=config_text,
255248
config={},
256249
)
250+
results.append(("local", path))
257251

252+
dest_label, primary_path = _summarize_results(results)
258253
backup.config_text = config_text
259254
backup.config_hash = config_hash
260255
backup.file_size = len(config_text)
261-
backup.destination_type = dest_type
262-
backup.destination_path = saved_path
256+
backup.destination_type = dest_label
257+
backup.destination_path = primary_path
263258
backup.status = BackupStatus.success
264259
db.commit()
265260

266-
logger.info("Backup complete for %s: %d bytes, hash=%s...", device.hostname, len(config_text), config_hash[:12])
261+
logger.info(
262+
"Backup complete for %s: %d bytes, hash=%s..., destinations=%s",
263+
device.hostname, len(config_text), config_hash[:12], dest_label,
264+
)
267265
return backup
268266

269267

app/modules/destinations/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ async def save(self, hostname: str, config_text: str, config: dict[str, Any]) ->
88
"""Save config and return the path/location where it was stored."""
99
pass
1010

11+
async def save_binary(
12+
self,
13+
hostname: str,
14+
data: bytes,
15+
extension: str,
16+
config: dict[str, Any],
17+
) -> str:
18+
"""Save a binary archive (e.g. .tar.gz from Proxmox) and return its path.
19+
20+
Default raises NotImplementedError so callers can skip backends that
21+
don't make sense for archives (e.g. committing a tarball to git).
22+
"""
23+
raise NotImplementedError(
24+
f"{self.__class__.__name__} does not support binary archive backups"
25+
)
26+
1127
@abstractmethod
1228
async def delete(self, path: str, config: dict[str, Any]) -> None:
1329
"""Delete a backup at the given path."""

0 commit comments

Comments
 (0)