diff --git a/Dockerfile b/Dockerfile index 807774d..7a6d25d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,9 @@ ENV NODE_ENV=production \ # Install minimal system libraries required for Chromium headless to run RUN apt-get update && apt-get install -y --no-install-recommends \ + cron \ + gettext-base \ + tzdata \ ca-certificates \ libglib2.0-0 \ libdbus-1-3 \ @@ -76,5 +79,11 @@ COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./ COPY --from=builder /usr/src/microsoft-rewards-script/node_modules ./node_modules -# Run the scheduled rewards script -CMD ["npm", "run", "start"] +# Copy runtime scripts with proper permissions from the start +COPY --chmod=755 src/run_daily.sh ./src/run_daily.sh +COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template +COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh + +# Entrypoint handles TZ, initial run toggle, cron templating & launch +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["sh", "-c", "echo 'Container started; cron is running.'"] diff --git a/compose.yaml b/compose.yaml index dec388d..e8206b1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,25 +6,37 @@ services: # Volume mounts: Specify a location where you want to save the files on your local machine. volumes: - - ./src/accounts.json:/usr/src/microsoft-rewards-script/accounts.json:ro - - ./src/config.jsonc:/usr/src/microsoft-rewards-script/config.json:ro - - ./sessions:/usr/src/microsoft-rewards-script/sessions # Optional, saves your login session + - ./src/accounts.jsonc:/usr/src/microsoft-rewards-script/dist/accounts.json:ro + - ./src/config.jsonc:/usr/src/microsoft-rewards-script/dist/config.json:ro + - ./sessions:/usr/src/microsoft-rewards-script/dist/browser/sessions # Optional, saves your login session environment: - TZ: "America/Toronto" # Set your timezone for proper scheduling (used by image and scheduler) + TZ: "America/Toronto" # Set your timezone for proper scheduling NODE_ENV: "production" - # Force headless when running in Docker (uses Chromium Headless Shell only) - FORCE_HEADLESS: "1" - #SCHEDULER_DAILY_JITTER_MINUTES_MIN: "2" - #SCHEDULER_DAILY_JITTER_MINUTES_MAX: "10" - # Watchdog timeout per pass (minutes, default 180) - #SCHEDULER_PASS_TIMEOUT_MINUTES: "180" - # Run pass in child process (default true). Set to "false" to disable for debugging. - #SCHEDULER_FORK_PER_PASS: "true" + CRON_SCHEDULE: "0 7,16,20 * * *" # Customize your schedule, use crontab.guru for formatting + RUN_ON_START: "true" # Runs the script immediately on container startup + # Add scheduled start-time randomization (uncomment to customize or disable, default: enabled) + #MIN_SLEEP_MINUTES: "5" + #MAX_SLEEP_MINUTES: "50" + SKIP_RANDOM_SLEEP: "false" + + # Optionally set how long to wait before killing a stuck script run (prevents blocking future runs, default: 8 hours) + #STUCK_PROCESS_TIMEOUT_HOURS: "8" + + # Optional resource limits for the container + mem_limit: 4g + cpus: 2 + + # Health check - monitors if cron daemon is running to ensure scheduled jobs can execute + # Container marked unhealthy if cron process dies + #healthcheck: + test: ["CMD", "sh", "-c", "pgrep cron > /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + # Security hardening security_opt: - no-new-privileges:true - - # Use the built-in scheduler by default; override with `command:` for one-shot runs - command: ["npm", "run", "start"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..22d53b8 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensure Playwright uses preinstalled browsers +export PLAYWRIGHT_BROWSERS_PATH=0 + +# 1. Timezone: default to UTC if not provided +: "${TZ:=UTC}" +ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime +echo "$TZ" > /etc/timezone +dpkg-reconfigure -f noninteractive tzdata + +# 2. Validate CRON_SCHEDULE +if [ -z "${CRON_SCHEDULE:-}" ]; then + echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2 + echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2 + exit 1 +fi + +# 3. Initial run without sleep if RUN_ON_START=true +if [ "${RUN_ON_START:-false}" = "true" ]; then + echo "[entrypoint] Starting initial run in background at $(date)" + ( + cd /usr/src/microsoft-rewards-script || { + echo "[entrypoint-bg] ERROR: Unable to cd to /usr/src/microsoft-rewards-script" >&2 + exit 1 + } + # Skip random sleep for initial run, but preserve setting for cron jobs + SKIP_RANDOM_SLEEP=true src/run_daily.sh + echo "[entrypoint-bg] Initial run completed at $(date)" + ) & + echo "[entrypoint] Background process started (PID: $!)" +fi + +# 4. Template and register cron file with explicit timezone export +if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then + echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2 + exit 1 +fi + +# Export TZ for envsubst to use +export TZ +envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron +chmod 0644 /etc/cron.d/microsoft-rewards-cron +crontab /etc/cron.d/microsoft-rewards-cron + +echo "[entrypoint] Cron configured with schedule: $CRON_SCHEDULE and timezone: $TZ; starting cron at $(date)" + +# 5. Start cron in foreground (PID 1) +exec cron -f \ No newline at end of file diff --git a/src/crontab.template b/src/crontab.template new file mode 100644 index 0000000..5576966 --- /dev/null +++ b/src/crontab.template @@ -0,0 +1,2 @@ +# Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs +${CRON_SCHEDULE} TZ=${TZ} /bin/bash /usr/src/microsoft-rewards-script/src/run_daily.sh >> /proc/1/fd/1 2>&1 diff --git a/src/run_daily.sh b/src/run_daily.sh new file mode 100755 index 0000000..6024c6f --- /dev/null +++ b/src/run_daily.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +export PATH="/usr/local/bin:/usr/bin:/bin" +export PLAYWRIGHT_BROWSERS_PATH=0 +export TZ="${TZ:-UTC}" + +cd /usr/src/microsoft-rewards-script + +LOCKFILE=/tmp/run_daily.lock + +# ------------------------------- +# Function: Check and fix lockfile integrity +# ------------------------------- +self_heal_lockfile() { + # If lockfile exists but is empty → remove it + if [ -f "$LOCKFILE" ]; then + local lock_content + lock_content=$(<"$LOCKFILE" || echo "") + + if [[ -z "$lock_content" ]]; then + echo "[$(date)] [run_daily.sh] Found empty lockfile → removing." + rm -f "$LOCKFILE" + return + fi + + # If lockfile contains non-numeric PID → remove it + if ! [[ "$lock_content" =~ ^[0-9]+$ ]]; then + echo "[$(date)] [run_daily.sh] Found corrupted lockfile content ('$lock_content') → removing." + rm -f "$LOCKFILE" + return + fi + + # If lockfile contains PID but process is dead → remove it + if ! kill -0 "$lock_content" 2>/dev/null; then + echo "[$(date)] [run_daily.sh] Lockfile PID $lock_content is dead → removing stale lock." + rm -f "$LOCKFILE" + return + fi + fi +} + +# ------------------------------- +# Function: Acquire lock +# ------------------------------- +acquire_lock() { + local max_attempts=5 + local attempt=0 + local timeout_hours=${STUCK_PROCESS_TIMEOUT_HOURS:-8} + local timeout_seconds=$((timeout_hours * 3600)) + + while [ $attempt -lt $max_attempts ]; do + # Try to create lock with current PID + if (set -C; echo "$$" > "$LOCKFILE") 2>/dev/null; then + echo "[$(date)] [run_daily.sh] Lock acquired successfully (PID: $$)" + return 0 + fi + + # Lock exists, validate it + if [ -f "$LOCKFILE" ]; then + local existing_pid + existing_pid=$(<"$LOCKFILE" || echo "") + + echo "[$(date)] [run_daily.sh] Lock file exists with PID: '$existing_pid'" + + # If lockfile content is invalid → delete and retry + if [[ -z "$existing_pid" || ! "$existing_pid" =~ ^[0-9]+$ ]]; then + echo "[$(date)] [run_daily.sh] Removing invalid lockfile → retrying..." + rm -f "$LOCKFILE" + continue + fi + + # If process is dead → delete and retry + if ! kill -0 "$existing_pid" 2>/dev/null; then + echo "[$(date)] [run_daily.sh] Removing stale lock (dead PID: $existing_pid)" + rm -f "$LOCKFILE" + continue + fi + + # Check process runtime → kill if exceeded timeout + local process_age + if process_age=$(ps -o etimes= -p "$existing_pid" 2>/dev/null | tr -d ' '); then + if [ "$process_age" -gt "$timeout_seconds" ]; then + echo "[$(date)] [run_daily.sh] Killing stuck process $existing_pid (${process_age}s > ${timeout_hours}h)" + kill -TERM "$existing_pid" 2>/dev/null || true + sleep 5 + kill -KILL "$existing_pid" 2>/dev/null || true + rm -f "$LOCKFILE" + continue + fi + fi + fi + + echo "[$(date)] [run_daily.sh] Lock held by PID $existing_pid, attempt $((attempt + 1))/$max_attempts" + sleep 2 + ((attempt++)) + done + + echo "[$(date)] [run_daily.sh] Could not acquire lock after $max_attempts attempts; exiting." + return 1 +} + +# ------------------------------- +# Function: Release lock +# ------------------------------- +release_lock() { + if [ -f "$LOCKFILE" ]; then + local lock_pid + lock_pid=$(<"$LOCKFILE") + if [ "$lock_pid" = "$$" ]; then + rm -f "$LOCKFILE" + echo "[$(date)] [run_daily.sh] Lock released (PID: $$)" + fi + fi +} + +# Always release lock on exit — but only if we acquired it +trap 'release_lock' EXIT INT TERM + +# ------------------------------- +# MAIN EXECUTION FLOW +# ------------------------------- +echo "[$(date)] [run_daily.sh] Current process PID: $$" + +# Self-heal any broken or empty locks before proceeding +self_heal_lockfile + +# Attempt to acquire the lock safely +if ! acquire_lock; then + exit 0 +fi + +# Random sleep between MIN and MAX to spread execution +MINWAIT=${MIN_SLEEP_MINUTES:-5} +MAXWAIT=${MAX_SLEEP_MINUTES:-50} +MINWAIT_SEC=$((MINWAIT*60)) +MAXWAIT_SEC=$((MAXWAIT*60)) + +if [ "${SKIP_RANDOM_SLEEP:-false}" != "true" ]; then + SLEEPTIME=$(( MINWAIT_SEC + RANDOM % (MAXWAIT_SEC - MINWAIT_SEC) )) + echo "[$(date)] [run_daily.sh] Sleeping for $((SLEEPTIME/60)) minutes ($SLEEPTIME seconds)" + sleep "$SLEEPTIME" +else + echo "[$(date)] [run_daily.sh] Skipping random sleep" +fi + +# Start the actual script +echo "[$(date)] [run_daily.sh] Starting script..." +if npm start; then + echo "[$(date)] [run_daily.sh] Script completed successfully." +else + echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2 +fi + +echo "[$(date)] [run_daily.sh] Script finished" +# Lock is released automatically via trap