Crea servizio watchdog configurabile
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user