diff --git a/service_watcher.py b/service_watcher.py new file mode 100644 index 0000000..fd6a1fb --- /dev/null +++ b/service_watcher.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +service_watcher.py — prosty watcher do crona + +DZIAŁANIE +- Oblicza sumę SHA256 monitorowanego pliku. +- Porównuje z poprzednią wartością zapisaną w /dev/shm (tmpfs, RAM). +- Gdy są zmiany → restartuje podane usługi (systemd) i aktualizuje sumę. + +UŻYCIE + python3 service_watcher.py -f /ścieżka/do/pliku -s nginx -s php-fpm + python3 service_watcher.py -f /etc/nginx/nginx.conf -s nginx + # lista po przecinku też działa: + python3 service_watcher.py -f /etc/my.conf -s "nginx,php-fpm@1,redis" + +CRON (przykład: co minutę) + * * * * * /usr/bin/python3 /usr/local/bin/service_watcher.py -f /etc/nginx/nginx.conf -s nginx + * * * * * /usr/bin/python3 /usr/local/bin/service_watcher.py -f /etc/hosts -s dnsmasq.service -s dnsdist -s unbound -q + + +WYMAGANIA +- systemd (systemctl), uprawnienia do restartu usług (root lub sudoers). +- Python 3.6+ + +OPCJE + -f/--file monitorowany plik (wymagane) + -s/--service usługa; można podawać wiele razy lub jako CSV + --state-dir katalog na stan (domyślnie: /dev/shm/service_watcher) + --no-hash użyj tylko mtime+size (szybciej, mniej dokładnie) + -q/--quiet bez logów (kody wyjścia nadal znaczące) + +KODY WYJŚCIA + 0: brak zmian / restart(-y) OK + 1: błąd wykonania + 2: plik nie istnieje +""" + +import argparse +import hashlib +import os +import sys +import json +import fcntl +import subprocess +from pathlib import Path +from typing import List + +def parse_args(): + p = argparse.ArgumentParser(description="Restartuje usługi po zmianie pliku.") + p.add_argument("-f", "--file", required=True, help="Monitorowany plik.") + p.add_argument("-s", "--service", action="append", default=[], + help="Usługa (systemd). Można powtarzać lub podać CSV.") + p.add_argument("--state-dir", default="/dev/shm/service_watcher", + help="Katalog na stan (domyślnie: /dev/shm/service_watcher).") + p.add_argument("--no-hash", action="store_true", + help="Porównuj tylko mtime+size (bez SHA256).") + p.add_argument("-q", "--quiet", action="store_true", help="Tryb cichy.") + return p.parse_args() + +def log(msg: str, quiet: bool): + if not quiet: + print(msg) + +def split_services(svcs: List[str]) -> List[str]: + out = [] + for s in svcs: + if s: + out.extend([x.strip() for x in s.split(",") if x.strip()]) + return list(dict.fromkeys(out)) # bez duplikatów, zachowaj kolejność + +def file_signature(path: Path, use_hash: bool) -> str: + st = path.stat() + base = f"{st.st_mtime_ns}:{st.st_size}" + if not use_hash: + return base + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return base + ":" + h.hexdigest() + +def lockfile(path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + f = path.open("a+") + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + return f + +def state_paths(state_dir: Path, target_file: Path): + safe = target_file.as_posix().replace("/", "_") + meta = state_dir / f"{safe}.json" + lck = state_dir / f"{safe}.lock" + return meta, lck + +def load_state(p: Path) -> dict: + if not p.exists(): + return {} + try: + return json.loads(p.read_text() or "{}") + except Exception: + return {} + +def save_state(p: Path, data: dict): + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False)) + os.replace(tmp, p) + +def restart_service(svc: str) -> subprocess.CompletedProcess: + # pozwala działać z sudoers bez hasła dla systemctl + return subprocess.run( + ["systemctl", "restart", svc], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False + ) + +def main(): + args = parse_args() + file_path = Path(args.file) + + if not file_path.exists(): + print(f"Plik nie istnieje: {file_path}", file=sys.stderr) + sys.exit(2) + + services = split_services(args.service) + if not services: + print("Podaj co najmniej jedną usługę (-s/--service).", file=sys.stderr) + sys.exit(1) + + state_dir = Path(args.state_dir) + meta_path, lock_path = state_paths(state_dir, file_path) + + # blokada współbieżnych uruchomień (np. overlap w cron) + with lockfile(lock_path): + sig = file_signature(file_path, use_hash=not args.no_hash) + state = load_state(meta_path) + prev = state.get("signature") + + if prev == sig: + log("Brak zmian — nic do zrobienia.", args.quiet) + sys.exit(0) + + log(f"Wykryto zmianę w {file_path}. Restart usług: {', '.join(services)}", args.quiet) + + failures = [] + for svc in services: + res = restart_service(svc) + if res.returncode != 0: + failures.append((svc, res.stderr.strip())) + log(f"[FAIL] {svc}: {res.stderr.strip()}", args.quiet) + else: + log(f"[OK] {svc}", args.quiet) + + if failures: + # nie zapisuj nowej sygnatury, aby ponowić przy następnym uruchomieniu + print("Co najmniej jeden restart nie powiódł się.", file=sys.stderr) + for svc, err in failures: + print(f"{svc}: {err}", file=sys.stderr) + sys.exit(1) + + # zapisz nową sygnaturę po udanych restartach + state.update({"signature": sig}) + save_state(meta_path, state) + log("Zakończono pomyślnie.", args.quiet) + sys.exit(0) + +if __name__ == "__main__": + main()