Aggiunge tray app e backup manuale
This commit is contained in:
@@ -37,6 +37,15 @@ python -m pip install -e .
|
||||
python -m bakrest.watchdog_runner
|
||||
```
|
||||
|
||||
## Avvio tray app per test
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
python -m bakrest.tray_app
|
||||
```
|
||||
|
||||
La tray app legge lo stato prodotto dal servizio e offre il comando `Backup e spegni`. Se il backup fallisce, lo spegnimento non viene eseguito.
|
||||
|
||||
## Installazione servizio Windows
|
||||
|
||||
Da PowerShell avviato come amministratore:
|
||||
|
||||
@@ -80,6 +80,8 @@ Il backup deve essere eseguito dalla tray app quando l'utente seleziona `Backup
|
||||
|
||||
Il motore consigliato e' il vero `rsync`, eseguito da Python tramite `subprocess`. Python deve fare da orchestratore, non da reimplementazione di `rsync`.
|
||||
|
||||
Per la prima versione il programma non deve bloccare, intercettare o ritardare lo spegnimento normale del PC tramite menu Start o altri comandi Windows standard. Il comando `Backup e spegni` e' un percorso esplicito e consigliato, ma l'utente deve poter spegnere il computer con le vie usuali. La gestione del caso in cui l'utente dimentichi il backup prima dello spegnimento verra' progettata in una fase successiva.
|
||||
|
||||
Flusso previsto:
|
||||
|
||||
1. L'utente clicca la tray icon.
|
||||
@@ -93,6 +95,12 @@ Flusso previsto:
|
||||
9. Se il backup e' riuscito, la tray app spegne il computer.
|
||||
10. Se il backup fallisce, il computer non viene spento.
|
||||
|
||||
Se il server di backup non e' raggiungibile, la tray app deve mostrare una finestra di messaggio con testo simile a:
|
||||
|
||||
```text
|
||||
Server non raggiungibile. E' spento?
|
||||
```
|
||||
|
||||
## Registro dei cambiamenti
|
||||
|
||||
Il servizio watchdog deve registrare i cambiamenti in modo persistente, cosi' da non perdere eventi in caso di riavvio o crash.
|
||||
|
||||
@@ -5,6 +5,8 @@ description = "Windows watchdog service and tray backup utility."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pillow>=10.0.0",
|
||||
"pystray>=0.19.5",
|
||||
"watchdog>=4.0.0",
|
||||
"pywin32>=306; platform_system == 'Windows'",
|
||||
]
|
||||
|
||||
229
src/bakrest/backup.py
Normal file
229
src/bakrest/backup.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import AppConfig
|
||||
from .registry import ChangeEvent, ChangeRegistry
|
||||
from .server_check import check_server
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupResult:
|
||||
success: bool
|
||||
message: str
|
||||
copied_count: int = 0
|
||||
skipped_count: int = 0
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult:
|
||||
status = check_server(config.backup)
|
||||
if not status.reachable:
|
||||
message = f"Server backup non raggiungibile: {status.message}"
|
||||
logger.error(message)
|
||||
return BackupResult(False, message, error_code="server_unreachable")
|
||||
|
||||
registry = ChangeRegistry(config.storage.change_registry)
|
||||
pending = registry.list_pending()
|
||||
if not pending:
|
||||
logger.info("No pending files to back up")
|
||||
return BackupResult(True, "Nessun file in attesa", copied_count=0)
|
||||
|
||||
existing, skipped = _split_existing_files(pending)
|
||||
if skipped:
|
||||
logger.info("Skipped %s non-existing files", len(skipped))
|
||||
registry.mark_status([event.path for event in skipped], "ignored")
|
||||
|
||||
stable, unstable = _split_stable_files(existing, config)
|
||||
if unstable:
|
||||
logger.warning("Skipped %s unstable files", len(unstable))
|
||||
|
||||
if not stable:
|
||||
return BackupResult(
|
||||
False,
|
||||
"Nessun file stabile da copiare",
|
||||
copied_count=0,
|
||||
skipped_count=len(skipped) + len(unstable),
|
||||
)
|
||||
|
||||
files_from_path: str | None = None
|
||||
try:
|
||||
command = _build_rsync_command(config, stable)
|
||||
files_from_path = _extract_files_from_path(command)
|
||||
logger.info("Running rsync command: %s", _redact_command(command))
|
||||
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
except OSError as exc:
|
||||
message = f"Errore avvio rsync: {exc}"
|
||||
logger.exception(message)
|
||||
return BackupResult(
|
||||
False,
|
||||
message,
|
||||
skipped_count=len(skipped) + len(unstable),
|
||||
error_code="rsync_start_failed",
|
||||
)
|
||||
finally:
|
||||
if files_from_path:
|
||||
_delete_temp_file(files_from_path, logger)
|
||||
|
||||
if completed.stdout:
|
||||
logger.info("rsync stdout: %s", completed.stdout.strip())
|
||||
if completed.stderr:
|
||||
logger.warning("rsync stderr: %s", completed.stderr.strip())
|
||||
|
||||
if completed.returncode != 0:
|
||||
message = f"rsync fallito con codice {completed.returncode}"
|
||||
logger.error(message)
|
||||
return BackupResult(
|
||||
False,
|
||||
message,
|
||||
skipped_count=len(skipped) + len(unstable),
|
||||
error_code="rsync_failed",
|
||||
)
|
||||
|
||||
registry.mark_status([event.path for event in stable], "copied")
|
||||
message = f"Backup completato: {len(stable)} file copiati"
|
||||
logger.info(message)
|
||||
return BackupResult(True, message, copied_count=len(stable), skipped_count=len(skipped) + len(unstable))
|
||||
|
||||
|
||||
def shutdown_after_backup(config: AppConfig, logger: logging.Logger) -> bool:
|
||||
if not config.backup.shutdown_on_success:
|
||||
logger.info("Shutdown disabled by configuration")
|
||||
return False
|
||||
|
||||
logger.info("Executing shutdown command: %s", config.backup.shutdown_command)
|
||||
subprocess.run(config.backup.shutdown_command, shell=True, check=False)
|
||||
return True
|
||||
|
||||
|
||||
def _split_existing_files(events: list[ChangeEvent]) -> tuple[list[ChangeEvent], list[ChangeEvent]]:
|
||||
existing: list[ChangeEvent] = []
|
||||
skipped: list[ChangeEvent] = []
|
||||
for event in events:
|
||||
if event.event_type == "deleted":
|
||||
skipped.append(event)
|
||||
elif Path(event.path).is_file():
|
||||
existing.append(event)
|
||||
else:
|
||||
skipped.append(event)
|
||||
return existing, skipped
|
||||
|
||||
|
||||
def _split_stable_files(events: list[ChangeEvent], config: AppConfig) -> tuple[list[ChangeEvent], list[ChangeEvent]]:
|
||||
stable: list[ChangeEvent] = []
|
||||
unstable: list[ChangeEvent] = []
|
||||
for event in events:
|
||||
if _is_file_stable(Path(event.path), config):
|
||||
stable.append(event)
|
||||
else:
|
||||
unstable.append(event)
|
||||
return stable, unstable
|
||||
|
||||
|
||||
def _is_file_stable(path: Path, config: AppConfig) -> bool:
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if time.time() - stat.st_mtime < config.stability.min_age_seconds:
|
||||
return False
|
||||
|
||||
previous = (stat.st_size, stat.st_mtime_ns)
|
||||
for _ in range(config.stability.unchanged_checks_required):
|
||||
time.sleep(config.stability.unchanged_check_interval_seconds)
|
||||
try:
|
||||
current_stat = path.stat()
|
||||
except OSError:
|
||||
return False
|
||||
current = (current_stat.st_size, current_stat.st_mtime_ns)
|
||||
if current != previous:
|
||||
return False
|
||||
previous = current
|
||||
return True
|
||||
|
||||
|
||||
def _build_rsync_command(config: AppConfig, events: list[ChangeEvent]) -> list[str]:
|
||||
roots = _common_roots(config, events)
|
||||
if len(roots) == 1:
|
||||
source_root = roots[0]
|
||||
files_from_entries = [_relative_posix(Path(event.path), source_root) for event in events]
|
||||
else:
|
||||
source_root = Path(Path(event.path).anchor or os.sep)
|
||||
files_from_entries = [_relative_posix(Path(event.path), source_root) for event in events]
|
||||
|
||||
files_from_path = _write_files_from(files_from_entries)
|
||||
return [
|
||||
config.backup.rsync_path,
|
||||
"-av",
|
||||
"--relative",
|
||||
f"--files-from={files_from_path}",
|
||||
_source_arg(source_root),
|
||||
config.backup.remote_destination,
|
||||
]
|
||||
|
||||
|
||||
def _common_roots(config: AppConfig, events: list[ChangeEvent]) -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
for event in events:
|
||||
path = Path(event.path)
|
||||
for include_dir in config.watch.include_dirs:
|
||||
try:
|
||||
path.relative_to(include_dir)
|
||||
if include_dir not in roots:
|
||||
roots.append(include_dir)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
return roots
|
||||
|
||||
|
||||
def _write_files_from(entries: list[str]) -> str:
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
prefix="bakrest-files-",
|
||||
suffix=".txt",
|
||||
delete=False,
|
||||
)
|
||||
with handle:
|
||||
for entry in entries:
|
||||
handle.write(f"{entry}\n")
|
||||
return handle.name
|
||||
|
||||
|
||||
def _extract_files_from_path(command: list[str]) -> str | None:
|
||||
prefix = "--files-from="
|
||||
for item in command:
|
||||
if item.startswith(prefix):
|
||||
return item[len(prefix) :]
|
||||
return None
|
||||
|
||||
|
||||
def _delete_temp_file(path: str, logger: logging.Logger) -> None:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Could not delete temporary rsync file list: %s", path)
|
||||
|
||||
|
||||
def _relative_posix(path: Path, root: Path) -> str:
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def _source_arg(path: Path) -> str:
|
||||
value = str(path)
|
||||
if not value.endswith(("\\", "/")):
|
||||
value += "\\"
|
||||
return value
|
||||
|
||||
|
||||
def _redact_command(command: list[str]) -> str:
|
||||
return " ".join(command)
|
||||
@@ -46,6 +46,37 @@ class ChangeRegistry:
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
def list_pending(self) -> list[ChangeEvent]:
|
||||
with closing(self._connect()) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT path, event_type, old_path, size
|
||||
FROM changes
|
||||
WHERE status = 'pending'
|
||||
ORDER BY updated_at ASC
|
||||
"""
|
||||
).fetchall()
|
||||
return [
|
||||
ChangeEvent(path=row[0], event_type=row[1], old_path=row[2], size=row[3])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_status(self, paths: list[str], status: str) -> None:
|
||||
if not paths:
|
||||
return
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
placeholders = ", ".join("?" for _ in paths)
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute(
|
||||
f"""
|
||||
UPDATE changes
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE path IN ({placeholders})
|
||||
""",
|
||||
[status, timestamp, *paths],
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute(
|
||||
|
||||
113
src/bakrest/tray_app.py
Normal file
113
src/bakrest/tray_app.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import pystray
|
||||
|
||||
from .backup import run_backup, shutdown_after_backup
|
||||
from .config import AppConfig, load_config
|
||||
from .logging_setup import configure_logging
|
||||
from .registry import ChangeRegistry
|
||||
|
||||
|
||||
class BakRestTrayApp:
|
||||
def __init__(self, config: AppConfig, logger: logging.Logger) -> None:
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.registry = ChangeRegistry(config.storage.change_registry)
|
||||
self.icon = pystray.Icon(
|
||||
"BakRestTray",
|
||||
_create_icon_image(),
|
||||
"Bak&Rest",
|
||||
self._build_menu(),
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
self.logger.info("BakRestTray started")
|
||||
self.icon.run()
|
||||
|
||||
def _build_menu(self) -> pystray.Menu:
|
||||
return pystray.Menu(
|
||||
pystray.MenuItem(lambda item: self._status_text(), None, enabled=False),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Backup e spegni", self._backup_and_shutdown),
|
||||
pystray.MenuItem("Aggiorna stato", self._refresh),
|
||||
pystray.MenuItem("Esci", self._quit),
|
||||
)
|
||||
|
||||
def _status_text(self) -> str:
|
||||
pending = self.registry.pending_count()
|
||||
server = self._read_server_status()
|
||||
return f"Server: {server} | File in attesa: {pending}"
|
||||
|
||||
def _read_server_status(self) -> str:
|
||||
path = self.config.storage.status_file
|
||||
if not path.exists():
|
||||
return "sconosciuto"
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return "errore stato"
|
||||
return "raggiungibile" if data.get("reachable") else "non raggiungibile"
|
||||
|
||||
def _backup_and_shutdown(self, icon: pystray.Icon, item: pystray.MenuItem) -> None:
|
||||
threading.Thread(target=self._backup_and_shutdown_worker, daemon=True).start()
|
||||
|
||||
def _backup_and_shutdown_worker(self) -> None:
|
||||
self.logger.info("User requested Backup e spegni")
|
||||
self.icon.title = "Bak&Rest - backup in corso"
|
||||
result = run_backup(self.config, self.logger)
|
||||
if result.success:
|
||||
self.icon.title = "Bak&Rest - backup completato"
|
||||
shutdown_after_backup(self.config, self.logger)
|
||||
else:
|
||||
self.icon.title = "Bak&Rest - backup fallito"
|
||||
if result.error_code == "server_unreachable":
|
||||
_show_error_message("Bak&Rest", "Server non raggiungibile. E' spento?")
|
||||
else:
|
||||
_show_error_message("Bak&Rest", result.message)
|
||||
self.icon.update_menu()
|
||||
|
||||
def _refresh(self, icon: pystray.Icon, item: pystray.MenuItem) -> None:
|
||||
self.icon.update_menu()
|
||||
|
||||
def _quit(self, icon: pystray.Icon, item: pystray.MenuItem) -> None:
|
||||
self.logger.info("BakRestTray stopped")
|
||||
self.icon.stop()
|
||||
|
||||
|
||||
def _create_icon_image() -> Image.Image:
|
||||
image = Image.new("RGBA", (64, 64), (26, 38, 52, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rounded_rectangle((10, 14, 54, 50), radius=8, fill=(77, 163, 255, 255))
|
||||
draw.rectangle((18, 24, 46, 44), fill=(245, 248, 252, 255))
|
||||
draw.line((22, 34, 32, 42, 46, 26), fill=(26, 38, 52, 255), width=5)
|
||||
return image
|
||||
|
||||
|
||||
def _show_error_message(title: str, message: str) -> None:
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes("-topmost", True)
|
||||
try:
|
||||
messagebox.showerror(title, message, parent=root)
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
config = load_config()
|
||||
logger = configure_logging(config.storage.logs_dir, "tray")
|
||||
app = BakRestTrayApp(config, logger)
|
||||
app.run()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
52
tests/test_backup.py
Normal file
52
tests/test_backup.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bakrest.backup import _build_rsync_command, _split_existing_files
|
||||
from bakrest.config import parse_config
|
||||
from bakrest.registry import ChangeEvent
|
||||
|
||||
|
||||
def test_split_existing_files_ignores_deleted(tmp_path: Path) -> None:
|
||||
existing = tmp_path / "image.jpg"
|
||||
existing.write_text("ok", encoding="utf-8")
|
||||
events = [
|
||||
ChangeEvent(path=str(existing), event_type="modified"),
|
||||
ChangeEvent(path=str(tmp_path / "missing.jpg"), event_type="deleted"),
|
||||
]
|
||||
|
||||
present, skipped = _split_existing_files(events)
|
||||
|
||||
assert [event.path for event in present] == [str(existing)]
|
||||
assert [event.path for event in skipped] == [str(tmp_path / "missing.jpg")]
|
||||
|
||||
|
||||
def test_build_rsync_command_uses_files_from(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
image = source / "image.jpg"
|
||||
image.write_text("ok", encoding="utf-8")
|
||||
config = parse_config(
|
||||
{
|
||||
"watch": {
|
||||
"include_dirs": [str(source)],
|
||||
"include_extensions": [".jpg"],
|
||||
"exclude_dirs": [],
|
||||
"exclude_patterns": [],
|
||||
},
|
||||
"backup": {
|
||||
"rsync_path": "rsync",
|
||||
"remote_destination": "user@host:/backup/",
|
||||
"server_check": {"type": "tcp", "port": 22, "interval_seconds": 300, "timeout_seconds": 1},
|
||||
},
|
||||
},
|
||||
tmp_path / "config.toml",
|
||||
)
|
||||
|
||||
command = _build_rsync_command(config, [ChangeEvent(path=str(image), event_type="modified")])
|
||||
|
||||
assert command[0] == "rsync"
|
||||
assert "-av" in command
|
||||
assert "--relative" in command
|
||||
assert any(part.startswith("--files-from=") for part in command)
|
||||
assert command[-1] == "user@host:/backup/"
|
||||
Reference in New Issue
Block a user