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
+52
View File
@@ -12,6 +12,7 @@ Some repositories (e.g., Zammad) only use git tags without creating GitHub relea
- Built-in HTTP server for local feed access
- Rate-limit-aware GitHub API client
- Configurable via environment variables and CLI flags
- Daemon mode for automatic background checks
## Installation
@@ -60,6 +61,7 @@ ghrel # Main command
├── update <owner/repo> [--tags] [--release] # Update tracking mode
├── list # List all tracked repositories
├── check [--since N] # Fetch latest releases/tags
├── daemon [--interval 30m] # Run as background daemon
└── serve [--port 8080] [--host 127.0.0.1] # Start HTTP server
```
@@ -117,6 +119,56 @@ ghrel serve # Default: 127.0.0.1:8080
ghrel serve --port 9090 --host 0.0.0.0 # Custom port and host
```
### `daemon [--interval INTERVAL] [--pid-file PATH] [--since N]`
Run as a background daemon that periodically checks all tracked repositories for new releases or tags.
```bash
ghrel daemon # Default: 30m interval
ghrel daemon --interval 1h # Check every hour
ghrel daemon --interval 15m # Check every 15 minutes
ghrel daemon --pid-file /run/ghrel.pid # Custom PID file
ghrel daemon --since 24h # Only entries from last 24h
```
**Interval format**: `N` followed by `s` (seconds), `m` (minutes), `h` (hours), or `d` (days).
The daemon writes a PID file for status checking and sends structured logs to stderr (suitable for systemd journal). It handles `SIGTERM` and `SIGINT` for graceful shutdown.
#### Systemd Service
Create `/etc/systemd/system/ghrel-daemon.service`:
```ini
[Unit]
Description=GitHub Release Monitor Daemon
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/ghrel daemon --interval 30m --pid-file /run/ghrel.pid
Restart=on-failure
RestartSec=10
Environment=GH_TOKEN=your_token_here
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now ghrel-daemon
sudo systemctl status ghrel-daemon
```
View logs:
```bash
journalctl -u ghrel-daemon -f
```
```
## Configuration
| Setting | Default | Override |
+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)
+55
View File
@@ -75,6 +75,61 @@ class TestCliList:
assert "No repositories" in result.stdout
class TestParseInterval:
def test_seconds(self):
from src.cli import parse_interval
assert parse_interval("60s") == 60
def test_minutes(self):
from src.cli import parse_interval
assert parse_interval("30m") == 1800
def test_hours(self):
from src.cli import parse_interval
assert parse_interval("2h") == 7200
def test_days(self):
from src.cli import parse_interval
assert parse_interval("1d") == 86400
def test_invalid_format(self):
from src.cli import parse_interval
with pytest.raises(SystemExit):
parse_interval("invalid")
class TestCliDaemon:
def test_daemon_help(self):
result = subprocess.run(
[sys.executable, "-m", "src.cli", "daemon", "--help"],
capture_output=True, text=True
)
assert result.returncode == 0
assert "interval" in result.stdout.lower()
assert "pid-file" in result.stdout.lower()
def test_daemon_pid_file_write(self, tmp_path):
"""Test that daemon PID file writing works correctly."""
import os
from pathlib import Path
pid_file = Path(tmp_path / "daemon.pid")
pid_file.parent.mkdir(parents=True, exist_ok=True)
# Test PID file writing logic directly
pid_file.write_text(str(os.getpid()))
assert pid_file.exists()
assert pid_file.read_text() == str(os.getpid())
pid_file.unlink()
assert not pid_file.exists()
def test_cmd_check_for_daemon_empty(self, tmp_path):
"""Test daemon check with no repos (no error raised)."""
db_path = str(tmp_path / "test.db")
from src.cli import cmd_check_for_daemon
cmd_check_for_daemon(db_path) # Should not raise
class TestCliAddRemove:
def test_add_invalid_format(self, tmp_path):
db_path = str(tmp_path / "test.db")