diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 094b290..e9a195f 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -104,6 +104,9 @@ jobs: - name: simplerisk (full-stack) generator run: ./simplerisk/test_generate_dockerfile.sh + - name: simplerisk-minimal Apache worker setting + run: ./simplerisk-minimal/test_set_apache_workers.sh + - name: Fail if a generator left the committed Dockerfile modified run: | set -euo pipefail diff --git a/CLAUDE.md b/CLAUDE.md index df74b7e..47eb9d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ The entrypoint script handles: | `SIMPLERISK_DB_USERNAME/PASSWORD/DATABASE` | DB credentials | | `SIMPLERISK_CRON_SETUP` | Enable/disable PHP cron (default: enabled) | | `SIMPLERISK_CSRF_SECRET` | Override auto-generated CSRF secret | +| `APACHE_MAX_REQUEST_WORKERS` | Optional cap (1-2000) on simultaneous Apache workers, written by the entrypoint's `set_apache_workers`; unset keeps Apache's default of 150. Each request holds a database connection, so size it against the database's `max_connections`, the replica count, the cron job and the session connection (`SIMPLERISK_DB_FOR_SESSIONS`), and the container's memory. Invalid values are ignored with a warning; a failed write logs an error and keeps the default | | `ADMIN_USERNAME` | Username for initial admin user (only during `DB_SETUP`; idempotent) | | `ADMIN_PASSWORD` | Password for initial admin user | | `ADMIN_EMAIL` | Email for initial admin user | diff --git a/simplerisk-minimal/README.md b/simplerisk-minimal/README.md index e8c6931..0be21f2 100644 --- a/simplerisk-minimal/README.md +++ b/simplerisk-minimal/README.md @@ -75,6 +75,7 @@ docker run -d --name simplerisk -e SIMPLERISK_DB_PASSWORD=pass -e SIMPLERISK_DB_ | `SIMPLERISK_DB_SSL_CERT_PATH` | Empty string (`''`) | Path where SSL certificates, to contact the database, are located | | `SIMPLERISK_CRON_SETUP` | `enabled` | Install the cron job to run in this Docker container | | `SIMPLERISK_CSRF_SECRET` | Auto-generated | Override the auto-generated CSRF secret | +| `APACHE_MAX_REQUEST_WORKERS` | Apache default (150) | Maximum number of requests Apache serves at once (a whole number from 1 to 2000). Each request holds a database connection, so set this so that `replicas x APACHE_MAX_REQUEST_WORKERS` stays under the database's `max_connections`, leaving room for the cron jobs and for the second connection used when `SIMPLERISK_DB_FOR_SESSIONS` is `true`. Each worker is a full PHP process, so also keep it within the container's memory limit. It applies to the Apache prefork MPM the image ships with. An invalid value is ignored with a warning in the container log | | `ADMIN_USERNAME` | — | Username for an initial admin user created once during `DB_SETUP`. Requires `ADMIN_PASSWORD` and `ADMIN_EMAIL`. Skipped if the username already exists | | `ADMIN_PASSWORD` | — | Password for the initial admin user | | `ADMIN_EMAIL` | — | Email address for the initial admin user | diff --git a/simplerisk-minimal/common/entrypoint.sh b/simplerisk-minimal/common/entrypoint.sh index cf43d40..afbddbc 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -229,6 +229,56 @@ set_ssl_certificate(){ exec_cmd "chmod 600 $key" "Failed to set permissions on TLS private key. Exiting." } +set_apache_workers(){ + # Optional cap on how many requests Apache serves at once. Every request + # holds a database connection, so the default of 150 workers can exceed a + # small database's max_connections (a db.t3.micro allows about 60), and + # several containers sharing one database multiply it. The right number + # depends on the database and the replica count, which the image cannot + # know, so it is left to the operator. Unset keeps Apache's default. + local workers="${APACHE_MAX_REQUEST_WORKERS:-}" + local conf="${APACHE_MPM_CONF_PATH:-/etc/apache2/conf-enabled/simplerisk-mpm.conf}" + + # Remove what a previous start wrote, so unsetting the variable on a + # restarted container goes back to Apache's default + rm -f "$conf" + + if [ -z "$workers" ]; then + return 0 + fi + + # Strip leading zeros and bound the length before any arithmetic: bash + # arithmetic wraps at 64 bits, so a very long digit string could otherwise + # wrap into the valid range + local digits="${workers#"${workers%%[!0]*}"}" + + if ! [[ "$workers" =~ ^[0-9]+$ ]] || [ -z "$digits" ] || [ "${#digits}" -gt 4 ] || [ "$digits" -gt 2000 ]; then + print_log "apache_setup:warn" "APACHE_MAX_REQUEST_WORKERS must be a whole number between 1 and 2000 -- leaving Apache's default." + return 0 + fi + + workers="$digits" + + local content="# Written by the container entrypoint from APACHE_MAX_REQUEST_WORKERS"$'\n'""$'\n' + # ServerLimit defaults to 256 and silently caps MaxRequestWorkers + if [ "$workers" -gt 256 ]; then + content+=$'\t'"ServerLimit ${workers}"$'\n' + fi + content+=$'\t'"MaxRequestWorkers ${workers}"$'\n'"" + + # Do not claim a cap that was not applied: if the file cannot be written + # (for example a read-only root filesystem), say so and keep the default. + # A single printf is used because a failed redirect on a { } group does not + # reliably report failure. + if ! printf '%s\n' "$content" > "$conf"; then + print_log "apache_setup:error" "Could not write ${conf} -- leaving Apache's default." + rm -f "$conf" || true + return 0 + fi + + print_log "apache_setup:info" "Apache will serve at most ${workers} requests at once." +} + set_cron(){ # If SIMPLERISK_CRON_SETUP was passed and it is set to disabled if [[ -n "${SIMPLERISK_CRON_SETUP:-}" && "${SIMPLERISK_CRON_SETUP:-}" = disabled* ]]; then @@ -495,6 +545,8 @@ _main() { print_log "initial_setup:info" "No DB env vars provided; config.php will not be written. The SimpleRisk web installer will run at first request." fi + set_apache_workers + set_cron if [[ -n ${DB_SETUP:-} ]]; then diff --git a/simplerisk-minimal/test_set_apache_workers.sh b/simplerisk-minimal/test_set_apache_workers.sh new file mode 100755 index 0000000..7efe100 --- /dev/null +++ b/simplerisk-minimal/test_set_apache_workers.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Exercises set_apache_workers() from common/entrypoint.sh without starting +# Apache: the function is extracted and run against a temporary conf path. +# Usage: ./test_set_apache_workers.sh + +set -eo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# print_log is what the entrypoint defines; capture its output so the tests +# can assert on what an operator would see +print_log(){ echo "[$1] $2"; } + +# Pull just the function out of the entrypoint (from its header to the first +# closing brace at column 0). Fails loudly if the function moves or is renamed. +func="$(awk '/^set_apache_workers\(\) *\{/{p=1} p{print} p&&/^}/{exit}' "$here/common/entrypoint.sh")" +[ -n "$func" ] || { echo "FAIL: set_apache_workers not found in entrypoint.sh"; exit 1; } +eval "$func" + +export APACHE_MPM_CONF_PATH="$tmp/simplerisk-mpm.conf" +failures=0 +log="" + +check(){ + local name="$1"; shift + if "$@"; then echo "ok $name"; else echo "FAIL $name"; failures=$((failures+1)); fi +} + +# run : run the function in a subshell with the variable set to +# (or unset for an empty argument) and keep what it logged in $log +run(){ log="$( unset APACHE_MAX_REQUEST_WORKERS; [ -z "${1:-}" ] || export APACHE_MAX_REQUEST_WORKERS="$1"; set_apache_workers 2>&1 )"; } + +has(){ grep -q -- "$1" "$APACHE_MPM_CONF_PATH"; } +hasnt(){ ! grep -q -- "$1" "$APACHE_MPM_CONF_PATH"; } +absent(){ [ ! -e "$APACHE_MPM_CONF_PATH" ]; } +logged(){ [[ "$log" == *"$1"* ]]; } + +expected_300=$'# Written by the container entrypoint from APACHE_MAX_REQUEST_WORKERS\n\n\tServerLimit 300\n\tMaxRequestWorkers 300\n' + +run "" ; check "unset writes nothing" absent +run 40 ; check "40 sets MaxRequestWorkers" has "MaxRequestWorkers 40" +run 40 ; check "40 leaves ServerLimit alone" hasnt ServerLimit +run 40 ; check "40 logs the cap" logged "at most 40" +run 1 ; check "lower bound 1 is accepted" has "MaxRequestWorkers 1$" +run 256 ; check "256 does not raise ServerLimit" hasnt ServerLimit +run 257 ; check "257 raises ServerLimit" has "ServerLimit 257" +run 2000 ; check "upper bound 2000 is accepted" has "MaxRequestWorkers 2000" +run 300 ; check "300 writes the exact expected file" test "$(cat "$APACHE_MPM_CONF_PATH")" = "$expected_300" +run 0040 ; check "leading zeros are normalised" has "MaxRequestWorkers 40$" +run 000000000000000000000040 ; check "long zero padding is normalised" has "MaxRequestWorkers 40$" +run "" ; check "unsetting removes the earlier file" absent + +# Rejected values: no file, a warning, and never a claim that a cap was applied +for bad in abc 0 00 2001 -5 1.5 " 40" "40 " "4 0" 18446744073709553616 99999999999999999999 "40; rm -rf /" '$(touch x)' '`id`' $'40\n41' '٣'; do + run "$bad" + check "rejects '${bad//$'\n'/\\n}'" bash -c '[ ! -e "$1" ]' _ "$APACHE_MPM_CONF_PATH" + check " and warns instead of claiming a cap" bash -c '[[ "$1" == *"[apache_setup:warn]"* && "$1" != *"at most"* ]]' _ "$log" +done + +# A conf path that cannot be written: say so, write nothing, and do not claim the cap +saved="$APACHE_MPM_CONF_PATH" +export APACHE_MPM_CONF_PATH="$tmp/no-such-dir/simplerisk-mpm.conf" +run 40 +check "unwritable path logs an error" logged "[apache_setup:error]" +check "unwritable path does not claim a cap" bash -c '[[ "$1" != *"at most"* ]]' _ "$log" +check "unwritable path leaves no file" absent +export APACHE_MPM_CONF_PATH="$saved" + +# The entrypoint must actually call the function, before it hands off to Apache +call_line="$(grep -n '^[[:space:]]*set_apache_workers$' "$here/common/entrypoint.sh" | head -1 | cut -d: -f1)" +exec_line="$(grep -n '^[[:space:]]*exec "\$@"' "$here/common/entrypoint.sh" | tail -1 | cut -d: -f1)" +check "entrypoint calls set_apache_workers before exec" bash -c '[ -n "$1" ] && [ -n "$2" ] && [ "$1" -lt "$2" ]' _ "$call_line" "$exec_line" + +[ "$failures" -eq 0 ] || { echo "$failures check(s) failed"; exit 1; } +echo "all checks passed"