Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,13 @@ COPY config.example.json ./config.example.json

# Copy runtime scripts with proper permissions from the start
COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh
COPY --chmod=755 scripts/docker/healthcheck.sh ./scripts/docker/healthcheck.sh
COPY --chmod=755 scripts/api/ ./scripts/api/
COPY --chmod=644 scripts/package.json ./scripts/package.json
COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template
COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.sh

# Entrypoint handles TZ, accounts/config generation, initial run toggle,
# cron templating & launch
# cron templating & launch, or API server startup when API_MODE=true
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["sh", "-c", "echo 'Container started; cron is running.'"]
18 changes: 14 additions & 4 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ services:
# Load account credentials from the .env file (ACCOUNT_1_EMAIL, ACCOUNT_1_PASSWORD, etc.)
- path: .env
environment:
# Scheduling
# ── Scheduler ──────────────────────────────────────
TZ: 'America/Toronto'
NODE_ENV: 'production'
CRON_SCHEDULE: '0 7 * * *' # use crontab.guru to customize your schedule
Expand All @@ -23,15 +23,25 @@ services:
#MAX_SLEEP_MINUTES: "50"
#STUCK_PROCESS_TIMEOUT_HOURS: "8"

# Configuration
# ── API mode ──────────────────────────────────────
# Set API_MODE=true to run the API server for additional control options
#API_MODE: 'true'
#API_TOKEN: '${API_TOKEN}' # required for API mode, see example.env

# ── Configuration ──────────────────────────────────────
# Uncomment to override defaults, a full list of configuration options are in the README.
#CONFIG_CLUSTERS: '1'
#... add additional configuration overrides as needed

# ── API mode port mapping ──────────────────────────────────────
# Uncomment when API_MODE=true
#ports:
# - '3010:3010'

healthcheck:
test: ['CMD', 'sh', '-c', 'pgrep cron > /dev/null || exit 1']
test: ['CMD-SHELL', 'pgrep -x node >/dev/null 2>&1 || pgrep -x cron >/dev/null 2>&1']
interval: 60s
timeout: 10s
timeout: 5s
retries: 3
start_period: 30s

Expand Down
7 changes: 6 additions & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ ACCOUNT_1_PASSWORD=your_password
#ACCOUNT_2_PROXY_URL=
#ACCOUNT_2_PROXY_PORT=0
#ACCOUNT_2_PROXY_USERNAME=
#ACCOUNT_2_PROXY_PASSWORD=
#ACCOUNT_2_PROXY_PASSWORD=

# Control API (used when API_MODE=true in compose.yaml)
# See scripts/api/README.md for full documentation.
# Generate a token: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
#API_TOKEN=
10 changes: 10 additions & 0 deletions scripts/api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ const pm = new ProcessManager({

const startedAt = Date.now()

// Forward bot stdout/stderr to the API server's own output streams so that
// container logs (docker logs) continue to show the bot's output regardless
// of which mode started the run. Controller messages (run start/stop lifecycle
// events from ProcessManager) are included — they are low-volume and useful.
pm.on('log', entry => {
const line = (entry.raw ?? entry.message ?? '') + '\n'
if (entry.source === 'stderr') process.stderr.write(line)
else process.stdout.write(line)
})

function toHistoryRecord(entry) {
return {
startedAt: entry.startedAt,
Expand Down
95 changes: 95 additions & 0 deletions scripts/api/trigger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Triggers a run via the local API server and waits for it to finish.
*
* Called by scripts/docker/run_daily.sh when API_MODE=true so that cron
* delegates to the API server rather than running npm start directly. The
* API server has full visibility over every run, scheduled or
* manually triggered, and the dashboard can stream logs, stop a run, or
* inspect history regardless of how it was started.
*
*/

import http from 'node:http'

const PORT = Number(process.env.API_PORT) || 3010
const TOKEN = process.env.API_TOKEN || ''
const TIMEOUT_MS = (Number(process.env.STUCK_PROCESS_TIMEOUT_HOURS) || 8) * 60 * 60 * 1000
const POLL_MS = 15_000
const STARTUP_ATTEMPTS = 30
const STARTUP_DELAY_MS = 2_000

function request(method, path) {
return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json', 'Content-Length': '2' }
if (TOKEN) headers['Authorization'] = `Bearer ${TOKEN}`
const req = http.request(
{ host: '127.0.0.1', port: PORT, path, method, headers },
res => {
let raw = ''
res.on('data', c => (raw += c))
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(raw) }) }
catch { resolve({ status: res.statusCode, body: raw }) }
})
}
)
req.on('error', reject)
req.end('{}')
})
}

function sleep(ms) {
return new Promise(r => setTimeout(r, ms))
}

// Wait for the API server to be ready. Handles the RUN_ON_START race where
// trigger.js is launched in the background before the API server has started.
let ready = false
for (let i = 0; i < STARTUP_ATTEMPTS; i++) {
try {
const { status } = await request('GET', '/health')
if (status === 200) { ready = true; break }
} catch { /* server not up yet */ }
if (i < STARTUP_ATTEMPTS - 1) {
console.log(`[trigger] Waiting for API server (attempt ${i + 1}/${STARTUP_ATTEMPTS})…`)
await sleep(STARTUP_DELAY_MS)
}
}

if (!ready) {
console.error(`[trigger] API server did not respond after ${STARTUP_ATTEMPTS} attempts. Is API_MODE=true?`)
process.exit(1)
}

// Trigger the run.
const { status, body } = await request('POST', '/start')

if (status === 409) {
// A run is already in progress — the dashboard or a previous cron invocation
// beat us to it. Exit cleanly so the lockfile is released.
console.log('[trigger] A run is already in progress (409 Conflict). Skipping.')
process.exit(0)
}

if (status !== 202) {
console.error(`[trigger] POST /start failed (HTTP ${status}):`, JSON.stringify(body))
process.exit(1)
}

console.log('[trigger] Run started. Waiting for completion…')

// Poll /status until the run finishes or the timeout is reached.
const deadline = Date.now() + TIMEOUT_MS
while (Date.now() < deadline) {
await sleep(POLL_MS)
try {
const { body: s } = await request('GET', '/status')
if (s?.state === 'idle') {
console.log('[trigger] Run completed.')
process.exit(0)
}
} catch { /* momentary blip — keep polling */ }
}

console.error(`[trigger] Timed out after ${process.env.STUCK_PROCESS_TIMEOUT_HOURS || 8}h waiting for run to finish.`)
process.exit(1)
51 changes: 45 additions & 6 deletions scripts/docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ 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
# 2. Validate CRON_SCHEDULE (not required in API mode)
if [ "${API_MODE:-false}" != "true" ]; then
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
echo " To run the API server instead, set API_MODE=true." >&2
exit 1
fi
fi

# 3. Accounts: read directly from ACCOUNT_N_* env vars by the app at runtime.
Expand Down Expand Up @@ -312,6 +315,9 @@ chmod 600 /etc/container_env
# 5. Initial run without sleep if RUN_ON_START=true
# ─────────────────────────────────────────────────────────────────────────────
if [ "${RUN_ON_START:-false}" = "true" ]; then
# Always go through run_daily.sh so the lockfile is acquired and the same
# code path runs regardless of mode. In API mode, run_daily.sh calls
# trigger.js which waits for the API server to be ready before firing.
echo "[entrypoint] Starting initial run in background at $(date)"
(
cd "$SCRIPT_DIR" || {
Expand All @@ -324,7 +330,40 @@ if [ "${RUN_ON_START:-false}" = "true" ]; then
echo "[entrypoint] Background process started (PID: $!)"
fi

# 6. Template and register cron file
# ─────────────────────────────────────────────────────────────────────────────
# 6. Start: scheduler-only (default) or API-integrated mode
# ─────────────────────────────────────────────────────────────────────────────
# Default API_HOST to 0.0.0.0 so Docker port-mapping works out of the box.
: "${API_HOST:=0.0.0.0}"
export API_HOST

if [ "${API_MODE:-false}" = "true" ]; then
# API-integrated mode:
# - The API server is the main (foreground) process and becomes PID 1.
# - If CRON_SCHEDULE is set, cron also runs as a background daemon.
# run_daily.sh detects API_MODE=true and calls POST /start via
# scripts/api/trigger.js instead of running npm start directly, so the
# API server has full visibility and control over every run.
# - Without CRON_SCHEDULE, runs must be triggered manually via POST /start.
if [ -n "${CRON_SCHEDULE:-}" ]; then
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
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
cron -f &
echo "[entrypoint] Cron started in background (schedule: $CRON_SCHEDULE, TZ: $TZ)"
else
echo "[entrypoint] No CRON_SCHEDULE set — runs must be triggered manually via POST /start"
fi
echo "[entrypoint] Starting control API on ${API_HOST}:${API_PORT:-3010} at $(date)"
exec node scripts/api/server.js
fi

# Scheduler-only mode (default): cron calls npm start directly.
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
Expand Down
7 changes: 7 additions & 0 deletions scripts/docker/healthcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env sh
# Health check used by compose.yaml and as a standalone diagnostic.
#
# node is always PID 1 in API mode (API_MODE=true); cron is always PID 1 in
# scheduler mode (API_MODE unset). Checking for either covers both modes
# without needing to inspect API_MODE.
pgrep -x node >/dev/null 2>&1 || pgrep -x cron >/dev/null 2>&1
16 changes: 13 additions & 3 deletions scripts/docker/run_daily.sh
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,20 @@ fi

# Start the actual script
echo "[$(date)] [run_daily.sh] Starting script..."
if npm start; then
echo "[$(date)] [run_daily.sh] Script completed successfully."
if [ "${API_MODE:-false}" = "true" ]; then
# API-integrated mode: delegate to the API server so the dashboard has full
# visibility and control. trigger.js calls POST /start and waits for idle.
if node scripts/api/trigger.js; then
echo "[$(date)] [run_daily.sh] Script completed successfully (via API)."
else
echo "[$(date)] [run_daily.sh] ERROR: Script failed (via API)!" >&2
fi
else
echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2
if npm start; then
echo "[$(date)] [run_daily.sh] Script completed successfully."
else
echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2
fi
fi

echo "[$(date)] [run_daily.sh] Script finished"
Expand Down
Loading