From 91fb2def20b3678e6abebdb1548d90133b2f8f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Fri, 4 Sep 2026 13:20:32 +0200 Subject: [PATCH] feat: publish startup warnings for deployment checks Track actionable warnings per container in the shared data volume, refresh their heartbeat, and remove reports during orderly shutdown. --- .github/workflows/container-test.yml | 6 +- Dockerfile | 3 +- docker_warning_heartbeat | 47 ++++++++ .../conf.d/docker-warning-heartbeat.conf | 9 ++ start | 109 ++++++++++++++++-- tests/integration/test-checks | 32 ++++- tests/integration/test-warning-heartbeat | 61 ++++++++++ 7 files changed, 254 insertions(+), 13 deletions(-) create mode 100755 docker_warning_heartbeat create mode 100644 etc/supervisor/conf.d/docker-warning-heartbeat.conf create mode 100755 tests/integration/test-warning-heartbeat diff --git a/.github/workflows/container-test.yml b/.github/workflows/container-test.yml index 9a2a899ef..922814eb1 100644 --- a/.github/workflows/container-test.yml +++ b/.github/workflows/container-test.yml @@ -59,6 +59,8 @@ jobs: - name: Test Celery worker mode validation if: inputs.test == 'celery-single' run: python3 tests/integration/test-celery-mode.py + - name: Test startup warning heartbeat + run: ./tests/integration/test-warning-heartbeat - name: Test content run: ./tests/integration/test-content - name: Generate configuration @@ -96,7 +98,9 @@ jobs: run: ../tests/integration/test-inspect - name: Run Django Checks working-directory: docker-compose - run: ../tests/integration/test-checks + run: ../tests/integration/test-checks "$WEBLATE_TEST" + env: + WEBLATE_TEST: ${{ inputs.test }} - name: Verify supervisor working-directory: docker-compose run: ../tests/integration/test-supervisor diff --git a/Dockerfile b/Dockerfile index 1124cacd4..5c2f05d4e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,6 +74,7 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5m CMD ["/app/bin/health_ # Use Docker specific settings ENV DJANGO_SETTINGS_MODULE=weblate.settings_docker +ENV WEBLATE_DOCKER_CONTAINER=1 # Copy built environment COPY --from=build /app /app @@ -110,7 +111,7 @@ RUN rm -f /etc/localtime /etc/timezone \ && sed -i '/pam_rootok.so/a auth requisite pam_deny.so' /etc/pam.d/su # Entrypoint -COPY --link --chmod=0755 start health_check /app/bin/ +COPY --link --chmod=0755 start health_check docker_warning_heartbeat /app/bin/ EXPOSE 8080 VOLUME /app/data diff --git a/docker_warning_heartbeat b/docker_warning_heartbeat new file mode 100755 index 000000000..52d4c0fc7 --- /dev/null +++ b/docker_warning_heartbeat @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +report=${WEBLATE_DOCKER_WARNING_REPORT:-} +hostname=${WEBLATE_DOCKER_WARNING_HOSTNAME:-unknown} +service=${WEBLATE_DOCKER_WARNING_SERVICE:-all} +warnings=${WEBLATE_DOCKER_WARNING_MESSAGES:-} +sleep_pid= +if [ -z "$report" ]; then + exit 0 +fi + +initialize_report() { + mkdir -p "$report" + printf '%s\n' "$hostname" > "$report/hostname" + printf '%s\n' "$service" > "$report/service" + printf '%s' "$warnings" > "$report/warnings" + touch "$report/heartbeat" "$report" +} + +cleanup_report() { + if [ -n "$sleep_pid" ]; then + kill "$sleep_pid" 2> /dev/null || true + fi + rm -f -- \ + "$report/heartbeat" \ + "$report/hostname" \ + "$report/service" \ + "$report/warnings" || true + rmdir -- "$report" 2> /dev/null || true +} + +shutdown() { + cleanup_report + exit 0 +} + +trap shutdown HUP INT TERM +initialize_report + +while :; do + touch "$report/heartbeat" "$report" + sleep 60 & + sleep_pid=$! + wait "$sleep_pid" + sleep_pid= +done diff --git a/etc/supervisor/conf.d/docker-warning-heartbeat.conf b/etc/supervisor/conf.d/docker-warning-heartbeat.conf new file mode 100644 index 000000000..5a29a7c2f --- /dev/null +++ b/etc/supervisor/conf.d/docker-warning-heartbeat.conf @@ -0,0 +1,9 @@ +[program:docker-warning-heartbeat] +command = /app/bin/docker_warning_heartbeat +autorestart = true +stopasgroup = true +killasgroup = true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +redirect_stderr=true +priority = 50 diff --git a/start b/start index d8f4cc24b..e71bad68f 100755 --- a/start +++ b/start @@ -105,6 +105,88 @@ initialize_env_defaults() { export CLIENT_MAX_BODY_SIZE } +startup_warning() { + local message=$1 + + message=${message//$'\n'/ } + message=${message//$'\r'/ } + >&2 echo "Warning: $message" + if [[ -n ${WEBLATE_DOCKER_WARNING_MESSAGES:-} ]]; then + WEBLATE_DOCKER_WARNING_MESSAGES+=$'\n' + fi + WEBLATE_DOCKER_WARNING_MESSAGES+="$message" + export WEBLATE_DOCKER_WARNING_MESSAGES + if [[ -n ${WEBLATE_DOCKER_WARNING_REPORT:-} ]]; then + printf '%s\n' "$message" >> "$WEBLATE_DOCKER_WARNING_REPORT/warnings" || true + fi +} + +cleanup_startup_warning_report() { + local report=${WEBLATE_DOCKER_WARNING_REPORT:-} + + if [[ -z $report ]]; then + return 0 + fi + rm -f -- \ + "$report/heartbeat" \ + "$report/hostname" \ + "$report/service" \ + "$report/warnings" || true + rmdir -- "$report" 2> /dev/null || true + WEBLATE_DOCKER_WARNING_REPORT= +} + +initialize_startup_warning_report() { + local command=${1:-} + local data_dir=${WEBLATE_DATA_DIR:-/app/data} + local report_id + local warning_root="$data_dir/.docker-startup-warnings" + + if [[ ${WEBLATE_DOCKER_CONTAINER:-} != 1 || $command != "runserver" ]]; then + return 0 + fi + + report_id=$(cat /proc/sys/kernel/random/uuid) + WEBLATE_DOCKER_WARNING_REPORT="$warning_root/$report_id" + WEBLATE_DOCKER_WARNING_HOSTNAME=${HOSTNAME:-unknown} + WEBLATE_DOCKER_WARNING_SERVICE=${WEBLATE_SERVICE:-all} + WEBLATE_DOCKER_WARNING_MESSAGES= + if ! mkdir -p "$WEBLATE_DOCKER_WARNING_REPORT"; then + >&2 echo "Could not initialize Docker startup warning report." + WEBLATE_DOCKER_WARNING_REPORT= + return 0 + fi + printf '%s\n' \ + "Signature: 8a477f597d28d172789f06886806bc55d" \ + "# Docker startup warning reports are ephemeral." \ + > "$warning_root/CACHEDIR.TAG" || true + if ! { + printf '%s\n' "$WEBLATE_DOCKER_WARNING_HOSTNAME" > "$WEBLATE_DOCKER_WARNING_REPORT/hostname" && + printf '%s\n' "$WEBLATE_DOCKER_WARNING_SERVICE" > "$WEBLATE_DOCKER_WARNING_REPORT/service" && + : > "$WEBLATE_DOCKER_WARNING_REPORT/warnings" && + touch "$WEBLATE_DOCKER_WARNING_REPORT/heartbeat" "$WEBLATE_DOCKER_WARNING_REPORT" + }; then + >&2 echo "Could not write Docker startup warning report." + cleanup_startup_warning_report + return 0 + fi + export \ + WEBLATE_DOCKER_WARNING_HOSTNAME \ + WEBLATE_DOCKER_WARNING_MESSAGES \ + WEBLATE_DOCKER_WARNING_REPORT \ + WEBLATE_DOCKER_WARNING_SERVICE + trap cleanup_startup_warning_report EXIT + + # Reports stop being displayed after five minutes. Remove expired ones + # here to keep the shared data directory bounded after abrupt shutdowns. + find "$warning_root" \ + -mindepth 1 \ + -maxdepth 1 \ + -type d \ + -mmin +5 \ + -exec rm -rf -- {} + 2> /dev/null || true +} + ensure_data_volume_writable() { if [[ ! -w /app/data ]]; then echo "The /app/data volume is not writable, please adjust the permissions. Weblate is running as uid $(id -u)" @@ -216,7 +298,7 @@ ensure_runtime_user() { case "${USER_NAME:-weblate}" in *[!A-Za-z0-9._-]* | "") - echo "Ignoring invalid USER_NAME value; using fallback username 'weblate'." + startup_warning "Ignoring invalid USER_NAME value; using fallback username 'weblate'." nss_wrapper_user_name=weblate ;; *) @@ -393,12 +475,12 @@ resolve_celery_worker_mode() { if [[ -z ${CELERY_WORKER_MODE:-} ]]; then if [[ ${CELERY_SINGLE_PROCESS:-0} == 1 ]]; then CELERY_WORKER_MODE=single - >&2 echo "Warning: CELERY_SINGLE_PROCESS is deprecated; use CELERY_WORKER_MODE=single instead." + startup_warning "CELERY_SINGLE_PROCESS is deprecated; use CELERY_WORKER_MODE=single instead." else CELERY_WORKER_MODE=combined fi elif [[ ${CELERY_SINGLE_PROCESS:-0} == 1 ]]; then - >&2 echo "Warning: CELERY_SINGLE_PROCESS is deprecated and ignored because CELERY_WORKER_MODE is set; remove CELERY_SINGLE_PROCESS." + startup_warning "CELERY_SINGLE_PROCESS is deprecated and ignored because CELERY_WORKER_MODE is set; remove CELERY_SINGLE_PROCESS." fi case $CELERY_WORKER_MODE in @@ -424,7 +506,7 @@ resolve_celery_worker_mode() { if ((${#split_options[@]})); then mode_options="CELERY_${CELERY_WORKER_MODE^^}_OPTIONS" - >&2 echo "Warning: CELERY_WORKER_MODE=$CELERY_WORKER_MODE ignores split worker options: ${split_options[*]}; use $mode_options or CELERY_WORKER_MODE=split." + startup_warning "CELERY_WORKER_MODE=$CELERY_WORKER_MODE ignores split worker options: ${split_options[*]}; use $mode_options or CELERY_WORKER_MODE=split." fi export CELERY_WORKER_MODE @@ -443,13 +525,21 @@ prepare_supervisor_services() { # Remove possible stale files from previous start. rm -f "$SUPERVISOR_CONF"/* + if [[ -n ${WEBLATE_DOCKER_WARNING_REPORT:-} ]]; then + ln -s /etc/supervisor/conf.d/docker-warning-heartbeat.conf "$SUPERVISOR_CONF" + fi + if [[ -n $WEBLATE_SERVICE ]]; then ln -s "/etc/supervisor/conf.d/$WEBLATE_SERVICE.conf" "$SUPERVISOR_CONF" return 0 fi # Symlink all non-celery services. - find /etc/supervisor/conf.d -type f ! -name 'celery-*.conf' -exec ln -s -t "$SUPERVISOR_CONF" {} + + find /etc/supervisor/conf.d \ + -type f \ + ! -name 'celery-*.conf' \ + ! -name 'docker-warning-heartbeat.conf' \ + -exec ln -s -t "$SUPERVISOR_CONF" {} + case $CELERY_WORKER_MODE in combined) @@ -540,7 +630,7 @@ configure_forwarded_for() { fi if [[ -n ${WEBLATE_IP_PROXY_OFFSET:-} && $WEBLATE_IP_PROXY_OFFSET != 0 ]]; then - echo "Ignoring WEBLATE_IP_PROXY_OFFSET=$WEBLATE_IP_PROXY_OFFSET: the built-in nginx normalizes X-Forwarded-For to one address." + startup_warning "Ignoring WEBLATE_IP_PROXY_OFFSET=$WEBLATE_IP_PROXY_OFFSET: the built-in nginx normalizes X-Forwarded-For to one address." fi export WEBLATE_IP_PROXY_OFFSET=0 } @@ -758,13 +848,14 @@ runserver_main() { main() { load_secret_env_files initialize_env_defaults - if [[ ${1:-} == "runserver" && -z ${WEBLATE_SERVICE:-} ]]; then - resolve_celery_worker_mode - fi echo "Starting Weblate $WEBLATE_VERSION..." prepare_runtime_files + initialize_startup_warning_report "$@" + if [[ ${1:-} == "runserver" && -z ${WEBLATE_SERVICE:-} ]]; then + resolve_celery_worker_mode + fi ensure_runtime_user require_site_domain export_runtime_env diff --git a/tests/integration/test-checks b/tests/integration/test-checks index 7a0f0fe6b..1095ebd1c 100755 --- a/tests/integration/test-checks +++ b/tests/integration/test-checks @@ -1,4 +1,32 @@ #!/bin/sh -docker compose exec -T \ + +OUTPUT=$(docker compose exec -T \ --user weblate \ - weblate weblate check --deploy --fail-level WARNING + weblate weblate check --deploy --fail-level WARNING 2>&1) +RESULT=$? + +SUPPORTS_DOCKER_WARNINGS=0 +if docker compose exec -T \ + --user weblate \ + weblate /app/venv/bin/python -c \ + 'from weblate.utils.checks import DOC_LINKS; raise SystemExit("weblate.W049" not in DOC_LINKS)'; then + SUPPORTS_DOCKER_WARNINGS=1 +fi + +if [ "${1:-}" = "celery-single-legacy" ] && [ "$SUPPORTS_DOCKER_WARNINGS" -eq 1 ]; then + if [ "$RESULT" -eq 0 ]; then + echo "Docker startup warning did not affect the deployment check" >&2 + exit 1 + fi + if ! echo "$OUTPUT" | grep -F "weblate.W049" > /dev/null; then + echo "$OUTPUT" >&2 + exit 1 + fi + if ! echo "$OUTPUT" | grep -F "CELERY_SINGLE_PROCESS is deprecated" > /dev/null; then + echo "$OUTPUT" >&2 + exit 1 + fi +elif [ "$RESULT" -ne 0 ]; then + echo "$OUTPUT" >&2 + exit "$RESULT" +fi diff --git a/tests/integration/test-warning-heartbeat b/tests/integration/test-warning-heartbeat new file mode 100755 index 000000000..92391a55b --- /dev/null +++ b/tests/integration/test-warning-heartbeat @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +DOCKER_ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd) +TEST_ROOT=$(mktemp -d) +REPORT="$TEST_ROOT/report" + +run_heartbeat() { + WEBLATE_DOCKER_WARNING_HOSTNAME=test-host \ + WEBLATE_DOCKER_WARNING_MESSAGES="Test warning" \ + WEBLATE_DOCKER_WARNING_REPORT="$REPORT" \ + WEBLATE_DOCKER_WARNING_SERVICE=test-service \ + "$DOCKER_ROOT/docker_warning_heartbeat" & + HEARTBEAT_PID=$! +} + +wait_for_heartbeat() { + ATTEMPTS=0 + while [ ! -f "$REPORT/heartbeat" ]; do + if ! kill -0 "$HEARTBEAT_PID" 2> /dev/null; then + wait "$HEARTBEAT_PID" + echo "Docker startup warning heartbeat exited before initialization" >&2 + exit 1 + fi + ATTEMPTS=$((ATTEMPTS + 1)) + if [ "$ATTEMPTS" -ge 50 ]; then + kill -TERM "$HEARTBEAT_PID" + wait "$HEARTBEAT_PID" + echo "Docker startup warning heartbeat was not initialized" >&2 + exit 1 + fi + sleep 0.1 + done +} + +stop_heartbeat() { + kill -TERM "$HEARTBEAT_PID" + wait "$HEARTBEAT_PID" +} + +assert_report_removed() { + if [ -d "$REPORT" ]; then + echo "Docker startup warning report was not removed on shutdown" >&2 + exit 1 + fi +} + +run_heartbeat +wait_for_heartbeat +grep -Fx "Test warning" "$REPORT/warnings" +stop_heartbeat +assert_report_removed + +# Supervisord can restart the heartbeat helper without rerunning the entrypoint. +run_heartbeat +wait_for_heartbeat +grep -Fx "Test warning" "$REPORT/warnings" +stop_heartbeat +assert_report_removed +rmdir "$TEST_ROOT"