diff --git a/scripts/job_runner.py b/scripts/job_runner.py new file mode 100755 index 0000000..8aa64c3 --- /dev/null +++ b/scripts/job_runner.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Generic job runner for scheduled tasks in Docker containers. + +Runs a Flask CLI command on a configurable interval with graceful shutdown support. + +Environment Variables: + JOB_NAME: Name of the job to run (zerotier_reconciliation, mfa_compliance) + JOB_INTERVAL_SECONDS: Seconds between job runs (default: 300) + +Usage: + docker run -e JOB_NAME=zerotier_reconciliation -e JOB_INTERVAL_SECONDS=120 app +""" + +import os +import signal +import subprocess +import sys +import time +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) + +JOB_COMMANDS = { + "zerotier_reconciliation": "python manage.py run_zerotier_reconciliation", + "mfa_compliance": "python manage.py run_mfa_compliance_job", +} + +shutdown_requested = False + + +def signal_handler(signum, frame): + global shutdown_requested + logger.info(f"Received signal {signum}, initiating graceful shutdown...") + shutdown_requested = True + + +def run_job(job_name: str) -> bool: + command = JOB_COMMANDS.get(job_name) + if not command: + logger.error(f"Unknown job: {job_name}. Valid jobs: {list(JOB_COMMANDS.keys())}") + return False + + logger.info(f"Running job: {job_name}") + start_time = time.monotonic() + + try: + result = subprocess.run( + command, + shell=True, + cwd="/app", + capture_output=False, + ) + elapsed = time.monotonic() - start_time + logger.info(f"Job {job_name} completed in {elapsed:.2f}s with exit code {result.returncode}") + return result.returncode == 0 + except Exception as e: + elapsed = time.monotonic() - start_time + logger.error(f"Job {job_name} failed after {elapsed:.2f}s: {e}") + return False + + +def main(): + job_name = os.getenv("JOB_NAME") + interval = int(os.getenv("JOB_INTERVAL_SECONDS", "300")) + + if not job_name: + logger.error("JOB_NAME environment variable is required") + sys.exit(1) + + if job_name not in JOB_COMMANDS: + logger.error(f"Unknown JOB_NAME: {job_name}. Valid: {list(JOB_COMMANDS.keys())}") + sys.exit(1) + + if interval < 10: + logger.error(f"JOB_INTERVAL_SECONDS must be at least 10 seconds, got {interval}") + sys.exit(1) + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + logger.info(f"Job runner started: {job_name}, interval={interval}s") + logger.info(f"Valid jobs: {list(JOB_COMMANDS.keys())}") + + while not shutdown_requested: + run_job(job_name) + + if shutdown_requested: + break + + logger.info(f"Sleeping for {interval}s until next run...") + + sleep_start = time.monotonic() + while time.monotonic() - sleep_start < interval and not shutdown_requested: + time.sleep(1) + + logger.info("Job runner stopped") + + +if __name__ == "__main__": + main()