- автообнаружение Docker Airflow через docker inspect и .env - мониторинг failed и long-running DAG-запусков с автоматическим retry - экспорт данных в файлы для Zabbix Agent через UserParameter - офлайн-сборка ZIP-архива для закрытых контуров - Zabbix шаблоны для 5.x (XML) и 6.x+ (YAML) - systemd сервис с graceful shutdown и lock file
188 lines
6.0 KiB
Python
188 lines
6.0 KiB
Python
"""Entry point for Airflow DAG Monitor.
|
|
|
|
Usage:
|
|
python -m airflow_monitor --config /path/to/config.yaml
|
|
python -m airflow_monitor --discover # auto-detect Docker Airflow
|
|
python -m airflow_monitor --discover --dry-run # show discovered config
|
|
"""
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import fcntl
|
|
import json
|
|
import logging
|
|
import logging.handlers
|
|
import os
|
|
import pathlib
|
|
import signal
|
|
import sys
|
|
import threading
|
|
|
|
from .config import AppConfig, load_config
|
|
from .discovery import DiscoveryError, discover_airflow
|
|
from .monitor import Monitor
|
|
|
|
|
|
def setup_logging(config) -> None:
|
|
"""Configure logging with rotating file handler and stdout."""
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(getattr(logging, config.level.upper(), logging.INFO))
|
|
|
|
formatter = logging.Formatter(
|
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
# Stdout handler (captured by journald when running as service)
|
|
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
stdout_handler.setFormatter(formatter)
|
|
root_logger.addHandler(stdout_handler)
|
|
|
|
# Rotating file handler
|
|
log_path = pathlib.Path(config.file)
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
file_handler = logging.handlers.RotatingFileHandler(
|
|
config.file,
|
|
maxBytes=config.max_bytes,
|
|
backupCount=config.backup_count,
|
|
encoding="utf-8",
|
|
)
|
|
file_handler.setFormatter(formatter)
|
|
root_logger.addHandler(file_handler)
|
|
except OSError as e:
|
|
root_logger.warning("Cannot open log file %s: %s (using stdout only)", config.file, e)
|
|
|
|
|
|
def acquire_lock(lock_path: str):
|
|
"""Acquire an exclusive lock to prevent multiple instances.
|
|
|
|
Returns the file descriptor (must be kept open for lock duration).
|
|
Exits with code 1 if another instance is running.
|
|
"""
|
|
lock_dir = pathlib.Path(lock_path).parent
|
|
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
fd = open(lock_path, "w")
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fd.write(str(os.getpid()))
|
|
fd.flush()
|
|
return fd
|
|
except BlockingIOError:
|
|
print(
|
|
f"Another instance is already running (lock: {lock_path})",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
except OSError as e:
|
|
print(f"Cannot acquire lock file {lock_path}: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _apply_discovery(config: AppConfig, discovered: dict) -> AppConfig:
|
|
"""Override airflow connection settings with discovered values."""
|
|
airflow = dataclasses.replace(
|
|
config.airflow,
|
|
base_url=discovered["base_url"],
|
|
username=discovered["username"],
|
|
password=discovered["password"],
|
|
api_version=discovered.get("api_version", "v1"),
|
|
verify_ssl=False, # Docker HTTP, not HTTPS
|
|
)
|
|
return dataclasses.replace(config, airflow=airflow)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Airflow DAG Monitor - monitors DAG runs and alerts via Zabbix",
|
|
)
|
|
parser.add_argument(
|
|
"--config", "-c",
|
|
default="config.yaml",
|
|
help="Path to config.yaml (default: config.yaml in current directory)",
|
|
)
|
|
parser.add_argument(
|
|
"--discover", "-d",
|
|
action="store_true",
|
|
help="Auto-discover Airflow Docker setup (overrides airflow section in config)",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="With --discover: show discovered config and exit without starting monitor",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Load config
|
|
try:
|
|
config = load_config(args.config)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
if args.discover:
|
|
# Config file optional when using discovery — use defaults
|
|
from .config import AirflowConfig, MonitorConfig, ZabbixConfig, LoggingConfig
|
|
config = AppConfig(
|
|
airflow=AirflowConfig(),
|
|
monitor=MonitorConfig(),
|
|
zabbix=ZabbixConfig(),
|
|
logging=LoggingConfig(),
|
|
)
|
|
else:
|
|
print(f"Configuration error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Auto-discovery
|
|
if args.discover:
|
|
try:
|
|
discovered = discover_airflow()
|
|
config = _apply_discovery(config, discovered)
|
|
print(f"Discovered Airflow at {discovered['base_url']}")
|
|
print(f" Compose dir: {discovered['compose_dir']}")
|
|
print(f" Container: {discovered['container_name']}")
|
|
print(f" User: {discovered['username']}")
|
|
except DiscoveryError as e:
|
|
print(f"Discovery failed: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.dry_run:
|
|
print("\n--- Effective Airflow config ---")
|
|
print(json.dumps(dataclasses.asdict(config.airflow), indent=2))
|
|
print("\n--- Effective Zabbix config ---")
|
|
print(json.dumps(dataclasses.asdict(config.zabbix), indent=2))
|
|
sys.exit(0)
|
|
|
|
# Setup logging
|
|
setup_logging(config.logging)
|
|
logger = logging.getLogger(__name__)
|
|
logger.info("Airflow DAG Monitor starting")
|
|
|
|
# Acquire lock
|
|
lock_fd = acquire_lock(config.monitor.lock_file)
|
|
logger.info("Lock acquired: %s (PID %d)", config.monitor.lock_file, os.getpid())
|
|
|
|
# Setup graceful shutdown
|
|
shutdown_event = threading.Event()
|
|
|
|
def handle_signal(signum, frame):
|
|
sig_name = signal.Signals(signum).name
|
|
logger.info("Received %s, initiating shutdown", sig_name)
|
|
shutdown_event.set()
|
|
|
|
signal.signal(signal.SIGTERM, handle_signal)
|
|
signal.signal(signal.SIGINT, handle_signal)
|
|
|
|
# Run monitor
|
|
try:
|
|
monitor = Monitor(config, shutdown_event)
|
|
monitor.run()
|
|
except Exception:
|
|
logger.exception("Fatal error")
|
|
sys.exit(1)
|
|
finally:
|
|
lock_fd.close()
|
|
logger.info("Monitor stopped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|