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
|
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
|
## Installazione servizio Windows
|
||||||
|
|
||||||
Da PowerShell avviato come amministratore:
|
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`.
|
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:
|
Flusso previsto:
|
||||||
|
|
||||||
1. L'utente clicca la tray icon.
|
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.
|
9. Se il backup e' riuscito, la tray app spegne il computer.
|
||||||
10. Se il backup fallisce, il computer non viene spento.
|
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
|
## Registro dei cambiamenti
|
||||||
|
|
||||||
Il servizio watchdog deve registrare i cambiamenti in modo persistente, cosi' da non perdere eventi in caso di riavvio o crash.
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"pillow>=10.0.0",
|
||||||
|
"pystray>=0.19.5",
|
||||||
"watchdog>=4.0.0",
|
"watchdog>=4.0.0",
|
||||||
"pywin32>=306; platform_system == 'Windows'",
|
"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()
|
).fetchone()
|
||||||
return int(row[0])
|
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:
|
def _initialize(self) -> None:
|
||||||
with closing(self._connect()) as connection:
|
with closing(self._connect()) as connection:
|
||||||
connection.execute(
|
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