Add daemon mode for background checks with systemd support

- Added daemon command with --interval, --pid-file, --since flags
- Configurable check interval (s/m/h/d format)
- PID file for status checking
- Graceful shutdown on SIGTERM/SIGINT
- Structured logging to stderr for systemd journal
- Updated README with daemon docs and systemd service template
- Added daemon tests (parse_interval, PID file, help)
This commit is contained in:
2026-07-28 12:05:07 +02:00
parent 2a5f11bd9b
commit 959d068474
3 changed files with 248 additions and 0 deletions
+141
View File
@@ -1,9 +1,14 @@
"""CLI entry point."""
import argparse
import logging
import os
import re
import signal
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Literal
from src import __version__
@@ -15,6 +20,36 @@ from src.config import (
from src.db import get_connection
from src.github import GitHubClient, AuthenticationError, NotFoundError
logger = logging.getLogger("ghrel")
shutdown_event = None
def setup_logging(verbose: bool = False) -> None:
"""Setup logging for daemon mode."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stderr,
)
def parse_interval(value: str) -> int:
"""Parse an interval string like '30m', '2h', '1d' into seconds."""
m = re.match(r"^(\d+)([smhd])$", value.strip().lower())
if not m:
error(f"Invalid interval format '{value}'. Expected format: 30m, 2h, 1d")
num, unit = int(m.group(1)), m.group(2)
if unit == "s":
return num
elif unit == "m":
return num * 60
elif unit == "h":
return num * 3600
else: # 'd' for days
return num * 86400
REPO_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$")
@@ -170,6 +205,102 @@ def cmd_check(args: argparse.Namespace, db_path: str) -> None:
conn.close()
def cmd_daemon(args: argparse.Namespace, db_path: str) -> None:
"""Run as a background daemon that periodically checks for new releases/tags."""
global shutdown_event
setup_logging(args.verbose)
shutdown_event = False
interval = parse_interval(args.interval)
pid_file = Path(args.pid_file) if args.pid_file else None
def signal_handler(signum, frame):
global shutdown_event
logger.info("Received signal %s, shutting down...", signum)
shutdown_event = True
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# Write PID file
if pid_file:
pid_file.parent.mkdir(parents=True, exist_ok=True)
pid_file.write_text(str(os.getpid()))
logger.info("PID file written to %s", pid_file)
def cleanup():
if pid_file and pid_file.exists():
pid_file.unlink()
logger.info("Removed PID file %s", pid_file)
try:
logger.info("Daemon started (interval: %s, db: %s)", args.interval, db_path)
logger.info("Press Ctrl+C to stop")
while not shutdown_event:
try:
cmd_check_for_daemon(db_path, args.since)
except Exception as e:
logger.error("Check failed: %s", e, exc_info=args.verbose)
# Sleep in small increments to respond to signals promptly
elapsed = 0
while elapsed < interval and not shutdown_event:
time.sleep(min(1, interval - elapsed))
elapsed += 1
logger.info("Daemon shutting down gracefully")
finally:
cleanup()
def cmd_check_for_daemon(db_path: str, since: str = None) -> None:
"""Fetch latest releases/tags for all tracked repos (daemon version)."""
conn = get_connection(db_path)
try:
from src.db import get_repos_with_mode, upsert_entry
repos = get_repos_with_mode(conn)
if not repos:
logger.info("No repositories tracked")
return
since_delta = parse_since(since) if since else None
total_new = 0
with GitHubClient() as gh:
for repo_id, owner_repo, mode in repos:
try:
if mode == "tag":
items = gh.fetch_tags(owner_repo)
kind: Literal["release", "tag"] = "tag"
else:
items = gh.fetch_releases(owner_repo)
kind = "release"
for item in items:
if since_delta:
published = datetime.fromisoformat(item["published_at"].replace("Z", "+00:00"))
cutoff = datetime.now(timezone.utc) - since_delta
if published < cutoff:
continue
inserted = upsert_entry(
conn, repo_id, kind,
item["tag_name"], item["title"], item["body"],
item["published_at"], item["html_url"],
)
if inserted:
total_new += 1
except Exception as e:
logger.warning("Failed to check %s: %s", owner_repo, e)
conn.commit()
logger.info("Checked %d repos: %d new entries found", len(repos), total_new)
finally:
conn.close()
def cmd_serve(args: argparse.Namespace, db_path: str) -> None:
"""Start the HTTP server."""
host = args.host or DEFAULT_SERVER_HOST
@@ -234,6 +365,15 @@ def build_parser() -> argparse.ArgumentParser:
p_serve.add_argument("--port", type=int, default=DEFAULT_SERVER_PORT, help="Server port")
p_serve.add_argument("--host", default=DEFAULT_SERVER_HOST, help="Server host")
# daemon
p_daemon = subparsers.add_parser("daemon", help="Run as background daemon")
p_daemon.add_argument("--interval", default="30m",
help="Check interval (e.g., 30m, 2h, 1d). Default: 30m")
p_daemon.add_argument("--pid-file", default=None,
help="Path to PID file (e.g., /run/ghrel.pid)")
p_daemon.add_argument("--since", nargs="?", const="24h", default=None,
help="Only fetch entries since N hours/days/minutes ago (e.g., 24h, 2d)")
return parser
@@ -254,6 +394,7 @@ def main() -> None:
"list": cmd_list,
"check": cmd_check,
"serve": cmd_serve,
"daemon": cmd_daemon,
}
cmd = commands.get(args.command)