Skip to content
Open
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
15 changes: 15 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,18 @@ jobs:
- uses: actions/checkout@v4
- name: Run Docker build and test
run: bash test.sh
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Build and push Docker image
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKER_USER }}/${{ github.event.repository.name }}:latest
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@

This container runs a cron daemon configured by environment variables or an optional `config.json` file.

## Defining jobs with environment variables

Jobs can be scheduled directly with pairs of `CMD_n` and `INTERVAL_n` variables. Each pair defines one job:

```sh
docker run \
-e CMD_1="echo hello" -e INTERVAL_1="*/5 * * * *" \
-e CMD_2="date" -e INTERVAL_2="0 1 * * *" image
```

The index `n` starts at 1 and must increase sequentially with no gaps; the parser stops at the first missing pair.
Both variables of a pair are required. `INTERVAL_n` expects a standard five-field cron expression, and `CMD_n`
is any shell command.

### Overriding values from `config.json`

If a `CONFIG_FILE` is supplied, jobs from that file populate unset `CMD_n`/`INTERVAL_n` variables. Explicit
environment values override those from the file:

```json
# config.json
{
"jobs": [
{"cmd": "echo from file", "interval": "*/5 * * * *"}
]
}
```

```sh
docker run -v $(pwd)/config.json:/app/config.json -e CONFIG_FILE=/app/config.json \
-e CMD_1="echo from env" -e INTERVAL_1="*/10 * * * *" image
```

In this example the job runs `echo from env` every ten minutes, ignoring the `cmd` and `interval` from
`config.json`.

## Using a configuration file

1. Create a `config.json` containing jobs with a `cmd` and `interval`:
Expand Down
50 changes: 40 additions & 10 deletions run.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
# Enable strict error handling: exit on errors, undefined variables, and
# failures within pipelines.

generate_cron() {
bash /app/config_parser.sh
Expand All @@ -12,16 +14,36 @@ reload_crond() {
watch_config() {
local file="$1"
if command -v inotifywait >/dev/null 2>&1; then
inotifywait -m -e close_write,move,delete "$file" | while read -r _; do
generate_cron
reload_crond
done
local dir="$(dirname "$file")"
local base="$(basename "$file")"
inotifywait -m -e close_write,move,create,delete "$dir" --format '%e %f' |
while read -r events fname; do
[ "$fname" = "$base" ] || continue
if echo "$events" | grep -qE 'DELETE|MOVED_FROM'; then
echo "Warning: configuration file '$file' missing, skipping cron generation" >&2
elif [ -f "$file" ]; then
generate_cron
reload_crond
fi
done || true
else
local prev
prev="$(md5sum "$file" 2>/dev/null | awk '{print $1}')"
if [ -f "$file" ]; then
prev="$(md5sum "$file" 2>/dev/null | awk '{print $1}' || true)"
else
prev="missing"
echo "Warning: configuration file '$file' missing, skipping cron generation" >&2
fi
while sleep 5; do
if [ ! -f "$file" ]; then
if [ "$prev" != "missing" ]; then
prev="missing"
echo "Warning: configuration file '$file' missing, skipping cron generation" >&2
fi
continue
fi
local curr
curr="$(md5sum "$file" 2>/dev/null | awk '{print $1}')"
curr="$(md5sum "$file" 2>/dev/null | awk '{print $1}' || true)"
if [ "$curr" != "$prev" ]; then
prev="$curr"
generate_cron
Expand All @@ -32,13 +54,21 @@ watch_config() {
}

# Generate cron entries from environment/config
generate_cron
if [ -n "${CONFIG_FILE:-}" ]; then
if [ -f "$CONFIG_FILE" ]; then
generate_cron
else
echo "Warning: configuration file '$CONFIG_FILE' missing, skipping cron generation" >&2
fi
else
generate_cron
fi

crond -f -l 2 &
CROND_PID=$!

if [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then
if [ -n "${CONFIG_FILE:-}" ]; then
watch_config "$CONFIG_FILE" &
fi

wait $CROND_PID
wait "$CROND_PID"
30 changes: 3 additions & 27 deletions test.sh
Original file line number Diff line number Diff line change
@@ -1,31 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

IMAGE_NAME=docker-cron-test
LOG_FILE=test.log
TEMP_CONFIG=config.json
bash tests/config_parser_test.sh
bash tests/watch_config_test.sh

cleanup() {
rm -f "$TEMP_CONFIG" "$LOG_FILE"
}
trap cleanup EXIT

# Build docker image
docker build -t "$IMAGE_NAME" .

# Create temporary config file
cat > "$TEMP_CONFIG" <<'CFG'
{
"jobs": [
{"cmd": "echo hello from cron", "interval": "* * * * *"}
]
}
CFG

# Run config parser inside container and display generated crontab
docker run --rm -v "$(pwd)/$TEMP_CONFIG:/config.json" -e CONFIG_FILE=/config.json --entrypoint /bin/sh "$IMAGE_NAME" -c '/app/config_parser.sh; cat /etc/crontabs/root' | tee "$LOG_FILE"

# Verify expected command is present in log
grep -q "echo hello from cron" "$LOG_FILE"

echo "Test completed successfully"
echo "All tests passed"
30 changes: 30 additions & 0 deletions tests/config_parser_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,37 @@ CFG
rm -rf /etc/crontabs
}

test_env_overrides_config() {
local tmpdir
tmpdir=$(mktemp -d)
cat >"$tmpdir/config.json" <<'CFG'
{
"jobs": [
{"cmd": "/bin/echo hi", "interval": "0 1 * * *"}
]
}
CFG

CMD_1="/bin/date" INTERVAL_1="*/5 * * * *" CONFIG_FILE="$tmpdir/config.json" bash app/config_parser.sh

local expected=$'*/5 * * * * /bin/date'
local actual
actual=$(cat /etc/crontabs/root)
if [[ "$actual" != "$expected" ]]; then
echo "crontab content mismatch" >&2
echo "expected:" >&2
printf '%s\n' "$expected" >&2
echo "actual:" >&2
printf '%s\n' "$actual" >&2
exit 1
fi

rm -rf "$tmpdir"
rm -rf /etc/crontabs
}

test_writes_crontab_from_config
test_errors_when_missing_pair
test_env_overrides_config

echo "All config_parser tests passed"
82 changes: 82 additions & 0 deletions tests/watch_config_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail

# Ensure crond is available
if ! command -v crond >/dev/null 2>&1; then
if command -v busybox >/dev/null 2>&1; then
ln -sf "$(command -v busybox)" /usr/bin/crond
elif command -v apt-get >/dev/null 2>&1; then
apt-get update >/dev/null
apt-get install -y busybox >/dev/null
ln -sf /bin/busybox /usr/bin/crond
else
echo "crond not available, skipping watch_config test" >&2
exit 0
fi
fi

# Ensure config parser is accessible at /app
if [ ! -e /app ]; then
ln -s "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/app" /app
fi

# busybox crond expects /var/spool/cron/crontabs; map it to /etc/crontabs
if [ ! -e /var/spool/cron/crontabs ]; then
mkdir -p /var/spool/cron
mkdir -p /etc/crontabs
ln -s /etc/crontabs /var/spool/cron/crontabs
fi

TMPDIR=$(mktemp -d)
CONFIG="$TMPDIR/config.json"
LOG="$TMPDIR/run.log"
RUN_PID=0

cleanup() {
if [ "$RUN_PID" -ne 0 ]; then
pkill -P "$RUN_PID" >/dev/null 2>&1 || true
kill "$RUN_PID" >/dev/null 2>&1 || true
fi
rm -rf "$TMPDIR"
}
trap cleanup EXIT

cat >"$CONFIG" <<'CFG'
{
"jobs": [
{"cmd": "/bin/echo first", "interval": "* * * * *"}
]
}
CFG

CONFIG_FILE="$CONFIG" bash ./run.sh >"$LOG" 2>&1 &
RUN_PID=$!

# Allow initial generation
sleep 2

grep -q "echo first" /etc/crontabs/root

# Remove config file and ensure watcher keeps running
rm "$CONFIG"

sleep 7

kill -0 "$RUN_PID"

grep -q "configuration file.*missing" "$LOG"

# Recreate config file with different command
cat >"$CONFIG" <<'CFG'
{
"jobs": [
{"cmd": "/bin/echo second", "interval": "* * * * *"}
]
}
CFG

sleep 7

grep -q "echo second" /etc/crontabs/root

echo "watch_config test passed"