diff --git a/Dockerfile b/Dockerfile index a217944..1ed67e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,44 +1,20 @@ FROM python:3.12-slim -ARG SUPERCRONIC_VERSION=v0.2.48 -ARG SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-amd64 -ARG SUPERCRONIC_SHA1SUM=016b7c9aebfc8d9fd9526e8ba33b191fc524485f - -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates \ - && curl -fsSLO "$SUPERCRONIC_URL" \ - && echo "${SUPERCRONIC_SHA1SUM} supercronic-linux-amd64" | sha1sum -c - \ - && chmod +x supercronic-linux-amd64 \ - && mv supercronic-linux-amd64 /usr/local/bin/supercronic \ - && apt-get purge -y curl \ - && apt-get autoremove -y \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /app COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt -# Enthält sowohl den Sync/Mailer-Code als auch die api/-App. -# Welcher Teil tatsächlich läuft, entscheidet der Command in docker-compose.yml, -# nicht das Image selbst -- ein Image, drei mögliche Rollen (sync, mailer, api). COPY src/ /app/ -COPY docker/run-sync.sh /usr/local/bin/run-sync.sh -COPY docker/run-mailer.sh /usr/local/bin/run-mailer.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh -COPY docker/crontab.default /etc/crontabs/app-crontab - -RUN chmod +x /usr/local/bin/run-sync.sh /usr/local/bin/run-mailer.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh RUN mkdir -p /app/config \ && useradd -m -u 10001 syncuser \ - && chown -R syncuser:syncuser /app /etc/crontabs + && chown -R syncuser:syncuser /app USER syncuser HEALTHCHECK --interval=5m --timeout=10s --start-period=30s \ CMD test -f /tmp/last_sync_ok || exit 1 -# Default-Entrypoint startet den Sync+Mailer-Cron-Modus. -# Der Web/API-Service in docker-compose.yml überschreibt "command" komplett -# mit uvicorn und läuft damit im selben Image in einer anderen Rolle. ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/crontab.default b/docker/crontab.default deleted file mode 100644 index 8fbfeb0..0000000 --- a/docker/crontab.default +++ /dev/null @@ -1,2 +0,0 @@ -*/15 * * * * /usr/local/bin/run-sync.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 -0 7 * * * /usr/local/bin/run-mailer.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c44f3f7..d42e403 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -7,18 +7,7 @@ if [ $# -gt 0 ]; then exec "$@" fi -MAIL_HOUR="${MAIL_SEND_HOUR:-7}" - echo "Starte icloud-contacts-sync Container" -echo "Sync-Schedule: */15 * * * * (alle 15 Minuten, Delta-Sync)" -echo "Mailer-Schedule: taeglich um ${MAIL_HOUR} Uhr (falls MAILER_ENABLED=true)" +echo "Scheduler: Python-basiert (kein supercronic)" -# Crontab wird zur Laufzeit generiert, damit MAIL_SEND_HOUR konfigurierbar bleibt. -cat > /etc/crontabs/app-crontab <> /proc/1/fd/1 2>> /proc/1/fd/2 -0 ${MAIL_HOUR} * * * /usr/local/bin/run-mailer.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 -EOF - -/usr/local/bin/run-sync.sh || echo "Initialer Sync fehlgeschlagen, Cron laeuft trotzdem weiter" - -exec supercronic -json /etc/crontabs/app-crontab +exec python3 /app/scheduler.py diff --git a/docker/run-mailer.sh b/docker/run-mailer.sh deleted file mode 100644 index 289b67c..0000000 --- a/docker/run-mailer.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -set -e -cd /app -echo "$(date -Is) - Mailer-Lauf gestartet" -python3 /app/mailer.py -echo "$(date -Is) - Mailer-Lauf beendet" diff --git a/docker/run-sync.sh b/docker/run-sync.sh deleted file mode 100644 index 975ab47..0000000 --- a/docker/run-sync.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -set -e -cd /app -echo "$(date -Is) - Sync-Lauf gestartet" -python3 /app/sync.py -echo "$(date -Is) - Sync-Lauf beendet" -touch /tmp/last_sync_ok diff --git a/src/scheduler.py b/src/scheduler.py new file mode 100644 index 0000000..7c9b4cd --- /dev/null +++ b/src/scheduler.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Einfacher Cron-Scheduler fuer Sync und Mailer. +Ersetzt supercronic komplett — keine externe Abhaengigkeit noetig. + +Laeuft als PID 1 im Docker-Container und plant: + - Sync: alle 15 Minuten (Delta-Sync) + - Mailer: taeglich um MAIL_SEND_HOUR Uhr (falls MAILER_ENABLED=true) + +Der Scheduler faengt SIGTERM/SIGINT ab und beendet sich sauber, +damit Docker den Container korrekt stoppen kann.""" +import logging +import os +import signal +import subprocess +import sys +import time +from datetime import datetime, timedelta + +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger("scheduler") + +SYNC_INTERVAL_MINUTES = 15 +MAIL_SEND_HOUR = int(os.environ.get("MAIL_SEND_HOUR", "7")) +MAILER_ENABLED = os.environ.get("MAILER_ENABLED", "false").lower() == "true" + +_shutdown = False + + +def _handle_signal(signum, _frame): + global _shutdown + signame = signal.Signals(signum).name + logger.info("Signal %s empfangen, Scheduler faehrt herunter...", signame) + _shutdown = True + + +def run_script(name: str, script_path: str): + logger.info("--- %s-Lauf gestartet ---", name) + try: + result = subprocess.run( + [sys.executable, script_path], + cwd="/app", + timeout=300, + ) + if result.returncode == 0: + logger.info("%s-Lauf erfolgreich beendet", name) + else: + logger.error("%s-Lauf fehlgeschlagen (exit code %d)", name, result.returncode) + except subprocess.TimeoutExpired: + logger.error("%s-Lauf hat 5 Minuten ueberschritten, abgebrochen", name) + except Exception: + logger.exception("Unerwarteter Fehler waehrend %s-Lauf", name) + + +def run_sync(): + run_script("Sync", "/app/sync.py") + try: + with open("/tmp/last_sync_ok", "w") as f: + f.write(datetime.now().isoformat()) + except OSError: + pass + + +def run_mailer(): + if not MAILER_ENABLED: + return + run_script("Mailer", "/app/mailer.py") + + +def next_run_time(hour: int) -> datetime: + now = datetime.now() + target = now.replace(hour=hour, minute=0, second=0, microsecond=0) + if target <= now: + target += timedelta(days=1) + return target + + +def main(): + global _shutdown + + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + logger.info("Scheduler gestartet") + logger.info("Sync: alle %d Minuten", SYNC_INTERVAL_MINUTES) + if MAILER_ENABLED: + logger.info("Mailer: taeglich um %d:00 Uhr", MAIL_SEND_HOUR) + else: + logger.info("Mailer: deaktiviert (MAILER_ENABLED=false)") + + logger.info("Fuehre initialen Sync aus...") + run_sync() + + last_sync = datetime.now() + next_mailer = next_run_time(MAIL_SEND_HOUR) + + while not _shutdown: + now = datetime.now() + + if now >= last_sync + timedelta(minutes=SYNC_INTERVAL_MINUTES): + run_sync() + last_sync = now + + if MAILER_ENABLED and now >= next_mailer: + run_mailer() + next_mailer = next_run_time(MAIL_SEND_HOUR) + + time.sleep(10) + + logger.info("Scheduler beendet") + + +if __name__ == "__main__": + main()