Skip to content

Commit f94da39

Browse files
authored
Merge pull request #13702 from internetarchive/fix/nginx-gate-orphaned-mount
fix(deploy): the `nginx -t` gate was blind; test in a fresh container
2 parents 9cdfdea + ac69889 commit f94da39

2 files changed

Lines changed: 196 additions & 25 deletions

File tree

‎docker/ol-nginx-start.sh‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,17 @@ chmod 644 /etc/logrotate.d/nginx
2323
chown root:root /etc/logrotate.d/nginx
2424
logrotate --verbose /etc/logrotate.d/nginx
2525

26+
# Test the config before serving. Most of what nginx parses here comes from
27+
# olsystem over a bind mount, so a bad rule lands in this container without ever
28+
# having been through CI. Without this test `nginx -g` starts, hits [emerg], and
29+
# exits -- and `restart: unless-stopped` turns that into a crash loop that reads
30+
# as "nginx keeps dying" rather than "your config is broken on line N".
31+
#
32+
# This does not keep a bad config from taking the service down; it makes the
33+
# failure immediate and legible instead of a loop. The deploy-time gate in
34+
# scripts/deployment/deploy.sh (check_nginx_config) is what catches it early
35+
# enough to still be fixable, but that only guards the deploy path -- a manual
36+
# restart, a host reboot, or an unrelated `docker compose up` all arrive here.
37+
nginx -t || exit 1
38+
2639
nginx -g "daemon off;"

‎scripts/deployment/deploy.sh‎

Lines changed: 183 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ ALL_HOSTNAMES="ol-home0 ol-covers0 ol-www0 $WEB_HOSTNAMES"
3434
SERVER_SUFFIX=${SERVER_SUFFIX:-".us.archive.org"}
3535

3636
KILL_CRON=${KILL_CRON:-""}
37+
SKIP_NGINX_CHECK=${SKIP_NGINX_CHECK:-""}
38+
# Set by check_nginx_config when the gate is skipped, so the end-of-deploy
39+
# summary can say so too; see the comment on SKIP_NGINX_CHECK there.
40+
NGINX_CHECK_SKIPPED=0
3741
LATEST_TAG=$(curl -s https://api.github.com/repos/internetarchive/openlibrary/releases/latest | sed -n 's/.*"tag_name": "\([^"]*\)".*/\1/p')
3842
# Convert "deploy-2026-05-19-at-19-10" to "2026-05-19T19:10" (replace "-at-" with "T" and the final "-" in the time with ":")
3943
LATEST_TAG_TIMESTAMP=$(echo "$LATEST_TAG" | sed 's/^deploy-//; s/-at-/T/; s/-\([0-9][0-9]\)$/:\1/')
@@ -297,6 +301,23 @@ deploy_olsystem() {
297301
fi
298302
done
299303

304+
# Gate here, inside the function, NOT at the wizard's call site. A rule fix is
305+
# an olsystem-only change, so it ships via `deploy.sh olsystem` rather than by
306+
# running the whole weekly wizard -- and that CLI path would skip a gate that
307+
# lived in deploy_wizard. The highest-risk deploy would have been the
308+
# unguarded one. Keeping the call here means there is exactly one call site
309+
# per deploy function and no way to reach the swap without it.
310+
if [[ "$REPO" == "olsystem" ]]; then
311+
check_nginx_config olsystem
312+
fi
313+
314+
if [[ "$NGINX_CHECK_SKIPPED" == "1" ]]; then
315+
echo ""
316+
echo "[Warning] This deploy ran with SKIP_NGINX_CHECK=1. The nginx config"
317+
echo " was NOT validated. A bad ruleset will surface as an [emerg]"
318+
echo " the next time nginx restarts (olsystem#420)."
319+
fi
320+
300321
echo "Finished $REPO deployment at $(date)"
301322
echo "[Info] To reboot the servers, please run scripts/deployments/restart_all_servers.sh"
302323
if [ $CLEANUP -eq 1 ]; then
@@ -499,10 +520,30 @@ deploy_openlibrary() {
499520
done
500521
fi
501522

523+
# Gate again, and this is not redundant with the one in deploy_olsystem.
524+
# docker/nginx.conf and docker/web_nginx.conf are bind-mounted out of
525+
# /opt/openlibrary, which is still PRE-deploy when the olsystem gate runs, so
526+
# that gate validates new-olsystem against old-nginx.conf -- a combination
527+
# that never actually serves. Coupled changes slip straight through it: an
528+
# openlibrary PR adding `include /olsystem/etc/nginx/thing.conf` alongside an
529+
# olsystem PR adding the file passes there (old conf, no include) and then
530+
# [emerg]s at restart. Only here do both halves exist together.
531+
#
532+
# Runs before tag_deploy so a broken deploy is not tagged as good, and before
533+
# recreate_services so the live containers are still serving the old config.
534+
check_nginx_config openlibrary
535+
502536
if [[ "$TAG_DEPLOY" == "1" ]]; then
503537
tag_deploy
504538
fi
505539

540+
if [[ "$NGINX_CHECK_SKIPPED" == "1" ]]; then
541+
echo ""
542+
echo "[Warning] This deploy ran with SKIP_NGINX_CHECK=1. The nginx config"
543+
echo " was NOT validated. A bad ruleset will surface as an [emerg]"
544+
echo " the next time nginx restarts (olsystem#420)."
545+
fi
546+
506547
echo "Finished production deployment at $(date)"
507548
echo "To reboot the servers, please run scripts/deployments/restart_all_servers.sh"
508549

@@ -678,6 +719,14 @@ prune_docker () {
678719

679720
# Which compose service runs nginx on each host; the compose profile is the
680721
# hostname. Hosts absent from this list don't run nginx.
722+
#
723+
# All three belong here, even though ol-www0/web_nginx is the only one that loads
724+
# the ModSecurity ruleset (`modsecurity on` appears in docker/web_nginx.conf and
725+
# nowhere else). Every nginx service mounts the shared docker/nginx.conf, which
726+
# includes seven files out of /olsystem -- logging.conf, tagger.js, deny.conf,
727+
# is_blessed_ip.conf, is_blessed_ua.conf, is_sus_ip.conf, ua_rate_limit_key.conf
728+
# -- so a bad olsystem include crash-loops infobase_nginx and covers_nginx just
729+
# as readily. Narrowing this map to ol-www0 would leave those unguarded.
681730
nginx_service_for() {
682731
case "$1" in
683732
ol-www0) echo "web_nginx" ;;
@@ -689,24 +738,94 @@ nginx_service_for() {
689738

690739
# Validate the nginx config that the olsystem deploy just shipped.
691740
#
692-
# olsystem carries the ModSecurity ruleset, and docker/ol-nginx-start.sh execs
693-
# `nginx -g` with no prior `nginx -t` -- so a bad rule is not a failed deploy, it
694-
# is an [emerg] at startup that crash-loops the container and takes the site
695-
# down. See internetarchive/olsystem#420. Catching it here, immediately after the
696-
# olsystem deploy, means the running containers are still serving the previous
741+
# olsystem carries the ModSecurity ruleset and most of what nginx.conf includes,
742+
# none of which goes through CI -- so a bad rule is not a failed deploy, it is an
743+
# [emerg] the next time nginx starts, which takes the site down. See
744+
# internetarchive/olsystem#420. docker/ol-nginx-start.sh now runs `nginx -t` before
745+
# serving, so that failure is at least fast and legible rather than a crash loop,
746+
# but the container is still down either way. Catching it at deploy time is what
747+
# keeps it off the site: the running containers are still serving the previous
697748
# (good) config and nothing is user-visible yet.
698749
#
699-
# `exec` against the live container is the right call at THIS point in the
700-
# deploy: olsystem is bind-mounted as a directory (../olsystem:/olsystem), so the
701-
# running container already sees the freshly deployed rules. Do not reuse this
702-
# after an openlibrary code transfer -- copy_to_servers replaces /opt/openlibrary
703-
# wholesale (rm -rf + mv), which leaves the single-FILE bind mounts
704-
# (nginx.conf, web_nginx.conf) as orphaned inodes still holding the previous
705-
# deploy's config. A check there would need `run --rm --no-deps` instead, and
706-
# never --service-ports, which would contend for :80/:443 with the live container.
750+
# Called twice, from deploy_olsystem and from deploy_openlibrary, and neither
751+
# call alone is sufficient. The olsystem call runs while /opt/openlibrary is
752+
# still pre-deploy, so it sees new rules against the OLD nginx.conf; the
753+
# openlibrary call is the first point at which both halves of a coupled change
754+
# exist together. Each catches what the other cannot.
755+
#
756+
# This MUST use `run --rm`, not `exec`. A bind mount is resolved to an inode when
757+
# the container starts, and deploy_olsystem does not update /opt/olsystem in
758+
# place -- it does `mv /opt/olsystem /opt/olsystem_previous` followed by
759+
# `mv /opt/olsystem_new /opt/olsystem`. The running container keeps the old
760+
# inode, which is now reachable at /opt/olsystem_previous, so `exec ... nginx -t`
761+
# parses the PREVIOUS ruleset, passes, and the crash still arrives at restart.
762+
# Being visible live is a property of editing a file inside a mounted directory,
763+
# not of directory mounts as such: `mv` orphans a directory mount for exactly the
764+
# same reason it orphans the single-FILE mounts that copy_to_servers orphans one
765+
# function later. A fresh container re-resolves the path and sees the new rules.
766+
#
767+
# Never pass --service-ports here: it republishes the service's ports and fails
768+
# with "port is already allocated" against the live container on :80/:443.
769+
#
770+
# This depends on the nginx services declaring `command:` and not `entrypoint:`
771+
# (compose.production.yaml:132, 222, 260). `run ... nginx -t` overrides `command`,
772+
# so the test runs directly. Were that key `entrypoint:`, the start script would
773+
# run instead with `nginx -t` as ignored arguments, sail past its own check,
774+
# reach `nginx -g "daemon off;"` and block forever -- a hung deploy holding a
775+
# --rm container open. Converting command -> entrypoint looks like a harmless
776+
# refactor and would silently break this gate.
777+
#
778+
# `run` also sidesteps an `exec` edge case -- `exec` against a container that
779+
# happens to be down exits non-zero and would fail an otherwise healthy deploy.
780+
#
781+
# OLIMAGE is passed through for the same reason restart_servers.sh passes it: an
782+
# operator who exports OLIMAGE=<tag> for a deploy gets it honoured at restart, so
783+
# the gate has to resolve the same tag or it parses the config against one nginx
784+
# build while a different one serves it. Unset is the normal case and still
785+
# resolves ${OLIMAGE:-openlibrary/olbase:latest} -- the `:-` treats empty as
786+
# unset -- which is what the live containers resolve too.
787+
#
788+
# THIS CHECK IS NOT READ-ONLY. It can mutate the tree it is validating, and that
789+
# is worth knowing before you trust it. A fresh container resolves the
790+
# single-FILE mounts (../olsystem/etc/cron.d/certbot,
791+
# ../olsystem/etc/logrotate.d/nginx, and the per-service cron.d entries) against
792+
# the NEWLY deployed /opt/olsystem. Where the new olsystem no longer ships one of
793+
# those files, Docker creates an empty DIRECTORY at that host path, inside the
794+
# tree just deployed. Verified, not theoretical. `exec` never did this, because
795+
# it reused mounts resolved at the live container's start.
796+
#
797+
# Not a false-pass, and `recreate_services` does the same at restart, so the gate
798+
# makes it earlier rather than new. Left unfixed deliberately: every guard we
799+
# could see means parsing `docker compose config` over ssh to enumerate bind
800+
# sources, which is fragile machinery defending against a state that is already a
801+
# broken deploy -- if olsystem stopped shipping a file compose mounts, the live
802+
# containers break at the next restart regardless. If you are debugging this,
803+
# look for a zero-byte directory rather than a bad rule.
804+
#
805+
# It is also a standing argument for doing the test in ol-nginx-start.sh, where a
806+
# container is coming up anyway, rather than in a separate `run`.
807+
#
808+
# Known false-abort, distinct from a false-pass: `nginx -t` opens the certs named
809+
# in web_nginx.conf (ssl_certificate /etc/letsencrypt/...), which arrive via the
810+
# letsencrypt-data volume. On a host where certbot has never run, the test fails
811+
# on a missing cert and stops a deploy whose config is fine. If that happens,
812+
# confirm it is the cert and not a rule before reaching for SKIP_NGINX_CHECK.
813+
#
814+
# $1 is which deploy is calling -- "olsystem" (default) or "openlibrary". It only
815+
# steers the failure text, but it has to be passed: the two callers have just
816+
# swapped different trees, so they need different repos named and different
817+
# _previous paths offered for rollback. Telling an operator mid-deploy to roll
818+
# back the wrong directory is its own outage.
707819
check_nginx_config() {
820+
local CALLER="${1:-olsystem}"
821+
708822
if [[ "$SKIP_NGINX_CHECK" == "1" ]]; then
709823
echo "[Warning] Skipping nginx config test (SKIP_NGINX_CHECK=1)"
824+
# Re-announced at the end of the deploy. A warning printed only at the
825+
# moment it is set scrolls past during a 12-minute deploy, and the
826+
# failure mode for this gate is someone setting the skip once under
827+
# pressure and then keeping it set.
828+
NGINX_CHECK_SKIPPED=1
710829
return 0
711830
fi
712831

@@ -716,8 +835,14 @@ check_nginx_config() {
716835
local CHECKED=0
717836
local SERVICE
718837
local SERVER
838+
local CERT_FAILURE=0
839+
local RULE_FAILURE=0
719840

720-
echo "[Now] Testing nginx config against the newly deployed olsystem rules..."
841+
if [[ "$CALLER" == "openlibrary" ]]; then
842+
echo "[Now] Re-testing nginx config, now against the newly deployed docker/ configs..."
843+
else
844+
echo "[Now] Testing nginx config against the newly deployed olsystem rules..."
845+
fi
721846
for SERVER_NAME in $HOSTNAMES; do
722847
SERVICE=$(nginx_service_for "$SERVER_NAME")
723848
if [[ -z "$SERVICE" ]]; then
@@ -731,13 +856,22 @@ check_nginx_config() {
731856
if OUTPUT=$(ssh "$SERVER" "
732857
set -e
733858
cd /opt/openlibrary
734-
COMPOSE_FILE='$COMPOSE_FILE' HOSTNAME=\$HOSTNAME docker compose --profile $SERVER_NAME exec -T $SERVICE nginx -t
859+
COMPOSE_FILE='$COMPOSE_FILE' HOSTNAME=\$HOSTNAME OLIMAGE='$OLIMAGE' docker compose --profile $SERVER_NAME run --rm --no-deps -T $SERVICE nginx -t
735860
" 2>&1); then
736861
echo "✓"
737862
else
738863
echo "⚠"
739864
echo "$OUTPUT"
740865
FAILED=1
866+
# Classify rather than making a human under time pressure do it. A
867+
# missing cert is a false-abort on a host certbot has never run on;
868+
# a bad rule is the thing this gate exists to catch. They need
869+
# opposite responses and look alike in a wall of nginx output.
870+
if echo "$OUTPUT" | grep -qE 'cannot load certificate|BIO_new_file'; then
871+
CERT_FAILURE=1
872+
else
873+
RULE_FAILURE=1
874+
fi
741875
fi
742876
done
743877

@@ -748,13 +882,40 @@ check_nginx_config() {
748882

749883
if [ $FAILED -eq 1 ]; then
750884
echo ""
751-
echo "[Error] nginx config test FAILED on one or more hosts (see output above)."
752-
echo " olsystem is already deployed, but the new config is invalid --"
753-
echo " restarting nginx now would take the site down (olsystem#420)."
754-
echo " The running containers are still serving the previous config."
885+
if [ $RULE_FAILURE -eq 1 ]; then
886+
echo "[Error] nginx config test FAILED on one or more hosts (see output above)."
887+
if [[ "$CALLER" == "openlibrary" ]]; then
888+
echo " openlibrary is already deployed, but the new config is invalid --"
889+
echo " restarting nginx now would take the site down (olsystem#420)."
890+
echo " The running containers are still serving the previous config."
891+
echo ""
892+
echo " The olsystem rules passed on their own earlier in this deploy,"
893+
echo " so suspect docker/nginx.conf or docker/web_nginx.conf, which"
894+
echo " ship from the openlibrary repo -- most likely an include or a"
895+
echo " directive that expects an olsystem file that isn't there."
896+
echo ""
897+
echo " Fix it in openlibrary and deploy again, or roll back using the"
898+
echo " copy left in /opt/openlibrary_previous on each host."
899+
else
900+
echo " olsystem is already deployed, but the new config is invalid --"
901+
echo " restarting nginx now would take the site down (olsystem#420)."
902+
echo " The running containers are still serving the previous config."
903+
echo ""
904+
echo " Fix the ruleset in olsystem and deploy it again, or roll back"
905+
echo " using the copy left in /opt/olsystem_previous on each host."
906+
fi
907+
fi
908+
if [ $CERT_FAILURE -eq 1 ]; then
909+
echo "[Error] nginx could not load a TLS certificate (see output above)."
910+
if [ $RULE_FAILURE -eq 0 ]; then
911+
echo " No rule syntax error was reported -- this looks like a"
912+
echo " missing cert, not a bad ruleset. On a host where certbot"
913+
echo " has never issued one, /etc/letsencrypt is empty and this"
914+
echo " test aborts a deploy whose config is fine."
915+
echo " Confirm the cert exists before treating this as a rule bug."
916+
fi
917+
fi
755918
echo ""
756-
echo " Fix the ruleset in olsystem and deploy it again, or roll back"
757-
echo " using the copy left in /opt/olsystem_previous on each host."
758919
echo " To proceed anyway (NOT recommended), set SKIP_NGINX_CHECK=1."
759920
clean_exit
760921
fi
@@ -801,11 +962,8 @@ deploy_wizard() {
801962
read -p "[Now] Run olsystem deploy now? [Y/n]..." answer
802963
answer=${answer:-Y}
803964
if [[ "$answer" =~ ^[Yy]$ ]]; then
965+
# deploy_olsystem gates on `nginx -t` itself; see check_nginx_config.
804966
time deploy_olsystem
805-
# Gate on `nginx -t` here, not at restart time: olsystem ships the
806-
# ModSecurity ruleset, and a bad rule only surfaces as an nginx [emerg]
807-
# once something restarts. Fail now, while the site is still up.
808-
check_nginx_config
809967
fi
810968
echo ""
811969

0 commit comments

Comments
 (0)