diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..444ba6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM python:3.12-slim + +# 1. Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 2. Install Python deps +RUN pip install --upgrade pip +RUN pip install --no-cache-dir pyyaml + +# 3. Install Supercronic +ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64 \ + SUPERCRONIC=supercronic-linux-amd64 \ + SUPERCRONIC_SHA1SUM=cd48d45c4b10f3f0bfdd3a57d054cd05ac96812b + +RUN curl -fsSLO "$SUPERCRONIC_URL" \ + && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ + && chmod +x "$SUPERCRONIC" \ + && mv "$SUPERCRONIC" /usr/local/bin/supercronic + +# 4. Setup App Logic +WORKDIR /app +COPY manager.py /app/manager.py +COPY log_formatter.py /app/log_formatter.py + +# 5. Setup Defaults +# We create a separate folder to hold the "template" files +RUN mkdir /app/defaults +COPY defaults/config.yaml /app/defaults/config.yaml +COPY defaults/hello_world.py /app/defaults/hello_world.py + +# 6. Prepare the mount point +RUN mkdir /scripts +VOLUME ["/scripts"] + +ENTRYPOINT ["python3", "-u", "/app/manager.py"] \ No newline at end of file diff --git a/README.md b/README.md index e69de29..cc7fe25 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,9 @@ +Current Features: +- run python-scripts in the /scripts +- install dependencies +- config via config-file in /scripts +- auto-populate /scripts if empty +- hello_world.py demo in scripts folder +- scripts print to container logs + +Planned Features: diff --git a/defaults/config.yaml b/defaults/config.yaml new file mode 100644 index 0000000..15ca9b3 --- /dev/null +++ b/defaults/config.yaml @@ -0,0 +1,8 @@ +# 1. List libraries to install at startup +dependencies: + - datetime + +# 2. Define jobs (Script Name + Cron Schedule) +jobs: + - script: "hello_world.py" + schedule: "* * * * *" # Runs every minute \ No newline at end of file diff --git a/defaults/hello_world.py b/defaults/hello_world.py new file mode 100644 index 0000000..e823c35 --- /dev/null +++ b/defaults/hello_world.py @@ -0,0 +1,3 @@ +import datetime +print("Hello world") +print(f"the current date & time is: {datetime.datetime.now()}") \ No newline at end of file diff --git a/log_formatter.py b/log_formatter.py new file mode 100644 index 0000000..c4f6c8e --- /dev/null +++ b/log_formatter.py @@ -0,0 +1,39 @@ +import sys +import re + +# Regex zum Finden des Script-Namens im Command +# Sucht nach: job.command="... /scripts/(DATEINAME)" +script_pattern = re.compile(r'job\.command=".*?/scripts/([^"]+)"') + +# Regex zum Finden der eigentlichen Nachricht +# Sucht nach: msg="NACHRICHT" +msg_pattern = re.compile(r'msg="(.*?)"') + +def clean_log(): + # Liest Standard-Input (Pipe von Supercronic) + for line in sys.stdin: + # Wir interessieren uns nur für Zeilen, die von unseren Scripts kommen + # (channel=stdout oder channel=stderr) + if 'channel=stdout' in line or 'channel=stderr' in line: + + # Script Namen extrahieren + script_match = script_pattern.search(line) + script_name = script_match.group(1) if script_match else "Unknown" + + # Nachricht extrahieren + msg_match = msg_pattern.search(line) + message = msg_match.group(1) if msg_match else line.strip() + + # Gewünschtes Format ausgeben: [script.py] Nachricht + print(f"[{script_name}] {message}") + + # WICHTIG: Sofort flushen, damit Logs nicht verzögert erscheinen + sys.stdout.flush() + + elif 'level=error' in line or 'level=warning' in line: + # System-Fehler von Supercronic (z.B. Syntaxfehler im Cron) trotzdem anzeigen + print(f"[SYSTEM] {line.strip()}") + sys.stdout.flush() + +if __name__ == "__main__": + clean_log() \ No newline at end of file diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..29e7b50 --- /dev/null +++ b/manager.py @@ -0,0 +1,84 @@ +import os +import sys +import subprocess +import yaml +import shutil + +# Constants +SCRIPTS_DIR = "/scripts" +DEFAULTS_DIR = "/app/defaults" +CONFIG_PATH = os.path.join(SCRIPTS_DIR, "config.yaml") +CRONTAB_PATH = "/app/generated_crontab" + +def initialize_volume_if_empty(): + """Copies default files to /scripts if config.yaml is missing.""" + if not os.path.exists(CONFIG_PATH): + print("---------------------------------") + print("No config found in volume. Initializing with defaults...") + + # Ensure directory exists + os.makedirs(SCRIPTS_DIR, exist_ok=True) + + # Copy all files from defaults to scripts + if os.path.exists(DEFAULTS_DIR): + for filename in os.listdir(DEFAULTS_DIR): + src = os.path.join(DEFAULTS_DIR, filename) + dst = os.path.join(SCRIPTS_DIR, filename) + if os.path.isfile(src): + shutil.copy2(src, dst) + print(f" -> Created: {filename}") + print("Initialization complete.") + print("---------------------------------") + else: + print(f"Found existing config at {CONFIG_PATH}. Using it.") + +def install_dependencies(deps): + if not deps: + return + print(f"Installing {len(deps)} dependencies...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-cache-dir"] + deps) + +def generate_crontab(jobs): + print(f"Scheduling {len(jobs)} jobs...") + lines = [] + for job in jobs: + schedule = job['schedule'] + script = job['script'] + # Use -u for unbuffered output + command = f"python3 -u {SCRIPTS_DIR}/{script}" + lines.append(f"{schedule} {command}") + print(f" -> Loaded: {script} [{schedule}]") + + with open(CRONTAB_PATH, "w") as f: + f.write("\n".join(lines) + "\n") + +def main(): + # 1. Initialize Volume (The new feature) + initialize_volume_if_empty() + + # 2. Read Config + if not os.path.exists(CONFIG_PATH): + print(f"Error: {CONFIG_PATH} still not found after initialization.") + sys.exit(1) + + with open(CONFIG_PATH, "r") as f: + config = yaml.safe_load(f) + + # 3. Setup Environment + install_dependencies(config.get("dependencies", [])) + generate_crontab(config.get("jobs", [])) + + # 4. Start Supercronic + print("Starting Scheduler...") + print("--------------------------------------------") + sys.stdout.flush() + + # Wir starten Supercronic und leiten stderr (wo Logs sind) an stdout weiter + # und pipen das Ganze in unseren log_formatter.py + cmd = f"supercronic {CRONTAB_PATH} 2>&1 | python3 -u /app/log_formatter.py" + + # shell=True ist hier notwendig für die Pipe "|" + subprocess.call(cmd, shell=True) + +if __name__ == "__main__": + main() \ No newline at end of file