@@ -34,6 +34,10 @@ ALL_HOSTNAMES="ol-home0 ol-covers0 ol-www0 $WEB_HOSTNAMES"
3434SERVER_SUFFIX=${SERVER_SUFFIX:- " .us.archive.org" }
3535
3636KILL_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
3741LATEST_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 ":")
3943LATEST_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.
681730nginx_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.
707819check_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