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:
@@ -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
|
- Built-in HTTP server for local feed access
|
||||||
- Rate-limit-aware GitHub API client
|
- Rate-limit-aware GitHub API client
|
||||||
- Configurable via environment variables and CLI flags
|
- Configurable via environment variables and CLI flags
|
||||||
|
- Daemon mode for automatic background checks
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ ghrel # Main command
|
|||||||
├── update <owner/repo> [--tags] [--release] # Update tracking mode
|
├── update <owner/repo> [--tags] [--release] # Update tracking mode
|
||||||
├── list # List all tracked repositories
|
├── list # List all tracked repositories
|
||||||
├── check [--since N] # Fetch latest releases/tags
|
├── 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
|
└── 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
|
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
|
## Configuration
|
||||||
|
|
||||||
| Setting | Default | Override |
|
| Setting | Default | Override |
|
||||||
|
|||||||
+141
@@ -1,9 +1,14 @@
|
|||||||
"""CLI entry point."""
|
"""CLI entry point."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from src import __version__
|
from src import __version__
|
||||||
@@ -15,6 +20,36 @@ from src.config import (
|
|||||||
from src.db import get_connection
|
from src.db import get_connection
|
||||||
from src.github import GitHubClient, AuthenticationError, NotFoundError
|
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._-]+$")
|
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()
|
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:
|
def cmd_serve(args: argparse.Namespace, db_path: str) -> None:
|
||||||
"""Start the HTTP server."""
|
"""Start the HTTP server."""
|
||||||
host = args.host or DEFAULT_SERVER_HOST
|
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("--port", type=int, default=DEFAULT_SERVER_PORT, help="Server port")
|
||||||
p_serve.add_argument("--host", default=DEFAULT_SERVER_HOST, help="Server host")
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -254,6 +394,7 @@ def main() -> None:
|
|||||||
"list": cmd_list,
|
"list": cmd_list,
|
||||||
"check": cmd_check,
|
"check": cmd_check,
|
||||||
"serve": cmd_serve,
|
"serve": cmd_serve,
|
||||||
|
"daemon": cmd_daemon,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = commands.get(args.command)
|
cmd = commands.get(args.command)
|
||||||
|
|||||||
@@ -75,6 +75,61 @@ class TestCliList:
|
|||||||
assert "No repositories" in result.stdout
|
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:
|
class TestCliAddRemove:
|
||||||
def test_add_invalid_format(self, tmp_path):
|
def test_add_invalid_format(self, tmp_path):
|
||||||
db_path = str(tmp_path / "test.db")
|
db_path = str(tmp_path / "test.db")
|
||||||
|
|||||||
Reference in New Issue
Block a user