Crea servizio watchdog configurabile

This commit is contained in:
2026-06-30 20:28:02 +02:00
parent 124cc370e5
commit 4dca5fbf95
17 changed files with 904 additions and 0 deletions

3
src/bakrest/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Bak&Rest package."""
__version__ = "0.1.0"

153
src/bakrest/config.py Normal file
View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import shutil
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .default_config import DEFAULT_CONFIG_TOML
from .paths import default_config_path, expand_path
@dataclass(frozen=True)
class ServerCheckConfig:
type: str
port: int
interval_seconds: int
timeout_seconds: int
@dataclass(frozen=True)
class BackupConfig:
server_host: str
remote_destination: str
rsync_path: str
shutdown_on_success: bool
shutdown_command: str
server_check: ServerCheckConfig
@dataclass(frozen=True)
class WatchConfig:
include_dirs: tuple[Path, ...]
include_extensions: frozenset[str]
exclude_dirs: tuple[Path | str, ...]
exclude_patterns: tuple[str, ...]
@dataclass(frozen=True)
class StorageConfig:
change_registry: Path
logs_dir: Path
status_file: Path
@dataclass(frozen=True)
class StabilityConfig:
min_age_seconds: int
unchanged_check_interval_seconds: int
unchanged_checks_required: int
@dataclass(frozen=True)
class AppConfig:
path: Path
service_autostart: bool
tray_autostart: bool
watch: WatchConfig
backup: BackupConfig
storage: StorageConfig
stability: StabilityConfig
def ensure_default_config(path: Path | None = None) -> Path:
config_path = path or default_config_path()
if config_path.exists():
return config_path
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(DEFAULT_CONFIG_TOML, encoding="utf-8")
return config_path
def copy_example_config(destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(Path("config/config.example.toml"), destination)
def load_config(path: Path | None = None) -> AppConfig:
config_path = ensure_default_config(path)
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
return parse_config(data, config_path)
def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
service = data.get("service", {})
tray = data.get("tray", {})
watch = data.get("watch", {})
backup = data.get("backup", {})
server_check = backup.get("server_check", {})
storage = data.get("storage", {})
stability = data.get("stability", {})
include_dirs = tuple(expand_path(item) for item in watch.get("include_dirs", []))
include_extensions = frozenset(
_normalize_extension(item) for item in watch.get("include_extensions", [])
)
exclude_dirs = tuple(_normalize_exclude_dir(item) for item in watch.get("exclude_dirs", []))
storage_config = StorageConfig(
change_registry=expand_path(
storage.get("change_registry", "%PROGRAMDATA%\\BakRest\\changes.sqlite")
),
logs_dir=expand_path(storage.get("logs_dir", "%PROGRAMDATA%\\BakRest\\logs")),
status_file=expand_path(storage.get("status_file", "%PROGRAMDATA%\\BakRest\\status.json")),
)
return AppConfig(
path=path,
service_autostart=bool(service.get("autostart", True)),
tray_autostart=bool(tray.get("autostart", True)),
watch=WatchConfig(
include_dirs=include_dirs,
include_extensions=include_extensions,
exclude_dirs=exclude_dirs,
exclude_patterns=tuple(watch.get("exclude_patterns", [])),
),
backup=BackupConfig(
server_host=str(backup.get("server_host", "backup-server")),
remote_destination=str(backup.get("remote_destination", "")),
rsync_path=str(backup.get("rsync_path", "rsync")),
shutdown_on_success=bool(backup.get("shutdown_on_success", True)),
shutdown_command=str(backup.get("shutdown_command", "shutdown /s /t 0")),
server_check=ServerCheckConfig(
type=str(server_check.get("type", "tcp")).lower(),
port=int(server_check.get("port", 22)),
interval_seconds=int(server_check.get("interval_seconds", 300)),
timeout_seconds=int(server_check.get("timeout_seconds", 3)),
),
),
storage=storage_config,
stability=StabilityConfig(
min_age_seconds=int(stability.get("min_age_seconds", 60)),
unchanged_check_interval_seconds=int(
stability.get("unchanged_check_interval_seconds", 5)
),
unchanged_checks_required=int(stability.get("unchanged_checks_required", 2)),
),
)
def _normalize_extension(value: str) -> str:
value = value.strip().lower()
if not value:
return value
return value if value.startswith(".") else f".{value}"
def _normalize_exclude_dir(value: str) -> Path | str:
expanded = expand_path(value)
if "%" in value or not expanded.is_absolute():
return value.lower()
return expanded

43
src/bakrest/config_cli.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import argparse
from .config import ensure_default_config, load_config
from .paths import default_config_path
def main() -> int:
parser = argparse.ArgumentParser(description="Bak&Rest configuration helper")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("path", help="Print the active configuration path")
subparsers.add_parser("init", help="Create the default configuration if missing")
subparsers.add_parser("show", help="Load and print a short configuration summary")
args = parser.parse_args()
if args.command == "path":
print(default_config_path())
return 0
if args.command == "init":
print(ensure_default_config())
return 0
if args.command == "show":
config = load_config()
print(f"config: {config.path}")
print(f"service_autostart: {config.service_autostart}")
print(f"tray_autostart: {config.tray_autostart}")
print("include_dirs:")
for include_dir in config.watch.include_dirs:
print(f" - {include_dir}")
print(f"include_extensions: {', '.join(sorted(config.watch.include_extensions))}")
print(f"registry: {config.storage.change_registry}")
print(f"logs_dir: {config.storage.logs_dir}")
return 0
parser.error("Unknown command")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,66 @@
DEFAULT_CONFIG_TOML = """[service]
autostart = true
[tray]
autostart = true
[watch]
include_dirs = [
"%USERPROFILE%\\\\Desktop",
"%USERPROFILE%\\\\Documents",
"%USERPROFILE%\\\\Pictures",
"%USERPROFILE%\\\\Videos",
]
include_extensions = [
".jpg",
".jpeg",
".png",
".pdf",
".mp4",
".mov",
]
exclude_dirs = [
"%WINDIR%",
"%PROGRAMFILES%",
"%PROGRAMFILES(X86)%",
"%APPDATA%",
"%LOCALAPPDATA%",
"%TEMP%",
"$Recycle.Bin",
"System Volume Information",
"node_modules",
".git",
"__pycache__",
]
exclude_patterns = [
"~$*",
"*.tmp",
"*.bak",
"*.crdownload",
"*.part",
"*.lock",
]
[backup]
server_host = "backup-server"
remote_destination = "utente@backup-server:/backup/bak-rest/"
rsync_path = "rsync"
shutdown_on_success = true
shutdown_command = "shutdown /s /t 0"
[backup.server_check]
type = "tcp"
port = 22
interval_seconds = 300
timeout_seconds = 3
[stability]
min_age_seconds = 60
unchanged_check_interval_seconds = 5
unchanged_checks_required = 2
[storage]
change_registry = "%PROGRAMDATA%\\\\BakRest\\\\changes.sqlite"
logs_dir = "%PROGRAMDATA%\\\\BakRest\\\\logs"
status_file = "%PROGRAMDATA%\\\\BakRest\\\\status.json"
"""

67
src/bakrest/filters.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import fnmatch
from pathlib import Path
from .config import WatchConfig
class WatchFilter:
def __init__(self, config: WatchConfig) -> None:
self.config = config
def should_track(self, path: Path, is_directory: bool = False) -> bool:
if is_directory:
return False
if not self._is_inside_include_dir(path):
return False
if self._is_inside_excluded_dir(path):
return False
if self._matches_excluded_pattern(path):
return False
if self.config.include_extensions and path.suffix.lower() not in self.config.include_extensions:
return False
return True
def _is_inside_include_dir(self, path: Path) -> bool:
resolved = _safe_resolve(path)
for include_dir in self.config.include_dirs:
include_resolved = _safe_resolve(include_dir)
if _is_relative_to(resolved, include_resolved):
return True
return False
def _is_inside_excluded_dir(self, path: Path) -> bool:
resolved = _safe_resolve(path)
lower_parts = {part.lower() for part in resolved.parts}
for exclude_dir in self.config.exclude_dirs:
if isinstance(exclude_dir, Path):
if _is_relative_to(resolved, _safe_resolve(exclude_dir)):
return True
elif exclude_dir.lower() in lower_parts:
return True
return False
def _matches_excluded_pattern(self, path: Path) -> bool:
name = path.name.lower()
return any(fnmatch.fnmatch(name, pattern.lower()) for pattern in self.config.exclude_patterns)
def _safe_resolve(path: Path) -> Path:
try:
return path.resolve()
except OSError:
return path.absolute()
def _is_relative_to(path: Path, base: Path) -> bool:
try:
path.relative_to(base)
return True
except ValueError:
return False

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
def configure_logging(logs_dir: Path, component: str) -> logging.Logger:
logs_dir.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger(f"bakrest.{component}")
logger.setLevel(logging.INFO)
logger.handlers.clear()
log_path = logs_dir / f"{date.today().isoformat()}-{component}.log"
formatter = logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
return logger
def close_logger(logger: logging.Logger) -> None:
for handler in list(logger.handlers):
handler.flush()
handler.close()
logger.removeHandler(handler)

26
src/bakrest/paths.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
import os
from pathlib import Path
APP_DIR_NAME = "BakRest"
CONFIG_ENV_VAR = "BAKREST_CONFIG"
def program_data_dir() -> Path:
base = os.environ.get("PROGRAMDATA")
if base:
return Path(base) / APP_DIR_NAME
return Path.home() / f".{APP_DIR_NAME.lower()}"
def default_config_path() -> Path:
configured = os.environ.get(CONFIG_ENV_VAR)
if configured:
return Path(configured).expanduser()
return program_data_dir() / "config.toml"
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(value)).expanduser()

71
src/bakrest/registry.py Normal file
View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import sqlite3
from contextlib import closing
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@dataclass(frozen=True)
class ChangeEvent:
path: str
event_type: str
old_path: str | None = None
size: int | None = None
class ChangeRegistry:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
def record(self, event: ChangeEvent) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with closing(self._connect()) as connection:
connection.execute(
"""
INSERT INTO changes(path, event_type, old_path, size, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?)
ON CONFLICT(path) DO UPDATE SET
event_type = excluded.event_type,
old_path = excluded.old_path,
size = excluded.size,
status = 'pending',
updated_at = excluded.updated_at
""",
(event.path, event.event_type, event.old_path, event.size, timestamp, timestamp),
)
connection.commit()
def pending_count(self) -> int:
with closing(self._connect()) as connection:
row = connection.execute(
"SELECT COUNT(*) FROM changes WHERE status = 'pending'"
).fetchone()
return int(row[0])
def _initialize(self) -> None:
with closing(self._connect()) as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
old_path TEXT,
size INTEGER,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS idx_changes_status ON changes(status)"
)
connection.commit()
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.db_path)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import socket
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from .config import BackupConfig
@dataclass(frozen=True)
class ServerStatus:
reachable: bool
checked_at: str
message: str
def check_server(config: BackupConfig) -> ServerStatus:
checked_at = datetime.now(timezone.utc).isoformat()
if config.server_check.type != "tcp":
return ServerStatus(False, checked_at, f"Unsupported check type: {config.server_check.type}")
try:
with socket.create_connection(
(config.server_host, config.server_check.port),
timeout=config.server_check.timeout_seconds,
):
return ServerStatus(True, checked_at, "Server reachable")
except OSError as exc:
return ServerStatus(False, checked_at, str(exc))
def write_status(path: Path, status: ServerStatus, pending_count: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = asdict(status)
payload["pending_count"] = pending_count
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

65
src/bakrest/service.py Normal file
View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import sys
import servicemanager
import win32event
import win32service
import win32serviceutil
from .config import load_config
from .logging_setup import configure_logging
from .watcher import WatchdogRuntime
class BakRestWatchdogService(win32serviceutil.ServiceFramework):
_svc_name_ = "BakRestWatchdog"
_svc_display_name_ = "Bak&Rest Watchdog Service"
_svc_description_ = "Monitors configured folders and records file changes for Bak&Rest backups."
def __init__(self, args: list[str]) -> None:
super().__init__(args)
self.stop_event_handle = win32event.CreateEvent(None, 0, 0, None)
self.runtime: WatchdogRuntime | None = None
def SvcStop(self) -> None:
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
if self.runtime:
self.runtime.stop()
win32event.SetEvent(self.stop_event_handle)
def SvcDoRun(self) -> None:
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STARTED,
(self._svc_name_, ""),
)
config = load_config()
logger = configure_logging(config.storage.logs_dir, "service")
logger.info("Windows service started with configuration: %s", config.path)
self.runtime = WatchdogRuntime(config, logger)
try:
self.runtime.run()
except Exception:
logger.exception("BakRestWatchdog failed")
raise
def main() -> None:
_apply_configured_startup()
win32serviceutil.HandleCommandLine(BakRestWatchdogService)
def _apply_configured_startup() -> None:
if len(sys.argv) < 2 or sys.argv[1].lower() != "install":
return
if "--startup" in sys.argv:
return
config = load_config()
startup = "auto" if config.service_autostart else "manual"
sys.argv.extend(["--startup", startup])
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from .config import load_config
from .logging_setup import configure_logging
from .watcher import WatchdogRuntime
def main() -> int:
config = load_config()
logger = configure_logging(config.storage.logs_dir, "service")
logger.info("Loaded configuration: %s", config.path)
runtime = WatchdogRuntime(config, logger)
try:
runtime.run()
except KeyboardInterrupt:
logger.info("Interrupted by user")
runtime.stop()
return 0
if __name__ == "__main__":
raise SystemExit(main())

105
src/bakrest/watcher.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import logging
import threading
import time
from pathlib import Path
from .config import AppConfig
from .filters import WatchFilter
from .logging_setup import close_logger
from .registry import ChangeEvent, ChangeRegistry
from .server_check import check_server, write_status
class WatchdogRuntime:
def __init__(self, config: AppConfig, logger: logging.Logger) -> None:
self.config = config
self.logger = logger
self.stop_event = threading.Event()
self.registry = ChangeRegistry(config.storage.change_registry)
self.filter = WatchFilter(config.watch)
def run(self) -> None:
try:
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
except ImportError as exc:
raise RuntimeError("La libreria watchdog non e' installata") from exc
runtime = self
class Handler(FileSystemEventHandler):
def on_any_event(self, event: FileSystemEvent) -> None:
runtime.handle_event(event)
observer = Observer()
watched = 0
for include_dir in self.config.watch.include_dirs:
if include_dir.exists():
observer.schedule(Handler(), str(include_dir), recursive=True)
self.logger.info("Monitoring directory: %s", include_dir)
watched += 1
else:
self.logger.warning("Configured directory does not exist: %s", include_dir)
if watched == 0:
self.logger.warning("No existing configured directories to monitor")
observer.start()
self.logger.info("BakRestWatchdog started")
try:
self._run_health_loop()
finally:
observer.stop()
observer.join(timeout=10)
self.logger.info("BakRestWatchdog stopped")
close_logger(self.logger)
def stop(self) -> None:
self.stop_event.set()
def handle_event(self, event: object) -> None:
event_type = getattr(event, "event_type", "unknown")
if event_type == "opened" or event_type == "closed":
return
src_path = Path(str(getattr(event, "src_path", "")))
dest_path_value = getattr(event, "dest_path", None)
is_directory = bool(getattr(event, "is_directory", False))
target_path = Path(str(dest_path_value)) if dest_path_value else src_path
if not self.filter.should_track(target_path, is_directory=is_directory):
return
size = _safe_size(target_path)
change = ChangeEvent(
path=str(target_path),
event_type=event_type,
old_path=str(src_path) if dest_path_value else None,
size=size,
)
self.registry.record(change)
self.logger.info("Recorded %s: %s", event_type, target_path)
def _run_health_loop(self) -> None:
interval = self.config.backup.server_check.interval_seconds
while not self.stop_event.wait(interval):
status = check_server(self.config.backup)
pending_count = self.registry.pending_count()
write_status(self.config.storage.status_file, status, pending_count)
if status.reachable:
self.logger.info("Backup server reachable; pending files: %s", pending_count)
else:
self.logger.warning(
"Backup server unreachable: %s; pending files: %s",
status.message,
pending_count,
)
def _safe_size(path: Path) -> int | None:
try:
return path.stat().st_size
except OSError:
return None