Crea servizio watchdog configurabile
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Local runtime data
|
||||||
|
*.sqlite
|
||||||
|
*.log
|
||||||
|
status.json
|
||||||
|
config.local.toml
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Bak&Rest
|
||||||
|
|
||||||
|
Bak&Rest monitora cartelle configurate su Windows, registra i file creati, modificati, cancellati o spostati e prepara il backup successivo tramite tray app.
|
||||||
|
|
||||||
|
## Componenti
|
||||||
|
|
||||||
|
- `BakRestWatchdog`: servizio Windows di monitoraggio.
|
||||||
|
- `BakRestTray`: tray app che eseguira' il backup e lo spegnimento.
|
||||||
|
- `BakRestConfig`: GUI di configurazione.
|
||||||
|
|
||||||
|
## Configurazione
|
||||||
|
|
||||||
|
Alla prima esecuzione viene creato un file TOML modificabile in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%PROGRAMDATA%\BakRest\config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Per creare o trovare il file di configurazione:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m bakrest.config_cli init
|
||||||
|
python -m bakrest.config_cli path
|
||||||
|
```
|
||||||
|
|
||||||
|
In sviluppo si puo' usare un percorso diverso:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:BAKREST_CONFIG="C:\devel\bak&rest\config\config.toml"
|
||||||
|
python -m bakrest.watchdog_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avvio watchdog in console per test
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e .
|
||||||
|
python -m bakrest.watchdog_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installazione servizio Windows
|
||||||
|
|
||||||
|
Da PowerShell avviato come amministratore:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e .
|
||||||
|
python -m bakrest.service install
|
||||||
|
python -m bakrest.service start
|
||||||
|
```
|
||||||
|
|
||||||
|
Se `service.autostart = true`, il comando `install` configura il servizio con avvio automatico. Se e' `false`, il servizio viene installato con avvio manuale. Il valore puo' essere sempre sovrascritto passando esplicitamente le opzioni di `pywin32`.
|
||||||
|
|
||||||
|
Comandi utili:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m bakrest.service stop
|
||||||
|
python -m bakrest.service remove
|
||||||
|
python -m bakrest.service debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Il servizio si chiama `BakRestWatchdog`.
|
||||||
65
config/config.example.toml
Normal file
65
config/config.example.toml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
[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"
|
||||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[project]
|
||||||
|
name = "bak-and-rest"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Windows watchdog service and tray backup utility."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"watchdog>=4.0.0",
|
||||||
|
"pywin32>=306; platform_system == 'Windows'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
3
src/bakrest/__init__.py
Normal file
3
src/bakrest/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Bak&Rest package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
153
src/bakrest/config.py
Normal file
153
src/bakrest/config.py
Normal 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
43
src/bakrest/config_cli.py
Normal 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())
|
||||||
66
src/bakrest/default_config.py
Normal file
66
src/bakrest/default_config.py
Normal 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
67
src/bakrest/filters.py
Normal 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
|
||||||
34
src/bakrest/logging_setup.py
Normal file
34
src/bakrest/logging_setup.py
Normal 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
26
src/bakrest/paths.py
Normal 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
71
src/bakrest/registry.py
Normal 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)
|
||||||
38
src/bakrest/server_check.py
Normal file
38
src/bakrest/server_check.py
Normal 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
65
src/bakrest/service.py
Normal 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()
|
||||||
22
src/bakrest/watchdog_runner.py
Normal file
22
src/bakrest/watchdog_runner.py
Normal 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
105
src/bakrest/watcher.py
Normal 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
|
||||||
54
tests/test_config_and_filters.py
Normal file
54
tests/test_config_and_filters.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bakrest.config import parse_config
|
||||||
|
from bakrest.filters import WatchFilter
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_config_normalizes_extensions(tmp_path: Path) -> None:
|
||||||
|
config = parse_config(
|
||||||
|
{
|
||||||
|
"watch": {
|
||||||
|
"include_dirs": [str(tmp_path)],
|
||||||
|
"include_extensions": ["jpg", ".PNG"],
|
||||||
|
"exclude_dirs": [],
|
||||||
|
"exclude_patterns": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tmp_path / "config.toml",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.watch.include_extensions == frozenset({".jpg", ".png"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_accepts_included_extension(tmp_path: Path) -> None:
|
||||||
|
config = parse_config(
|
||||||
|
{
|
||||||
|
"watch": {
|
||||||
|
"include_dirs": [str(tmp_path)],
|
||||||
|
"include_extensions": [".jpg"],
|
||||||
|
"exclude_dirs": [],
|
||||||
|
"exclude_patterns": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tmp_path / "config.toml",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert WatchFilter(config.watch).should_track(tmp_path / "image.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_rejects_excluded_pattern(tmp_path: Path) -> None:
|
||||||
|
config = parse_config(
|
||||||
|
{
|
||||||
|
"watch": {
|
||||||
|
"include_dirs": [str(tmp_path)],
|
||||||
|
"include_extensions": [".jpg"],
|
||||||
|
"exclude_dirs": [],
|
||||||
|
"exclude_patterns": ["~$*"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tmp_path / "config.toml",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not WatchFilter(config.watch).should_track(tmp_path / "~$image.jpg")
|
||||||
Reference in New Issue
Block a user