-
Notifications
You must be signed in to change notification settings - Fork 124
Expand file tree
/
Copy pathwrapper.sh
More file actions
2566 lines (2416 loc) · 135 KB
/
Copy pathwrapper.sh
File metadata and controls
2566 lines (2416 loc) · 135 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# exit as soon as any of these commands fail, this prevents starting a database without certificates or with the wrong volume mount path
set -e
EXPECTED_VOLUME_MOUNT_PATH="/var/lib/postgresql/data"
# check if the Railway volume is mounted to the correct path
# we do this by checking the current mount path (RAILWAY_VOLUME_MOUNT_PATH) agiant the expected mount path
# if the paths are different, we print an error message and exit
# only perform this check if this image is deployed to Railway by checking for the existence of the RAILWAY_ENVIRONMENT variable
if [ -n "$RAILWAY_ENVIRONMENT" ] && [ "$RAILWAY_VOLUME_MOUNT_PATH" != "$EXPECTED_VOLUME_MOUNT_PATH" ]; then
echo "Railway volume not mounted to the correct path, expected $EXPECTED_VOLUME_MOUNT_PATH but got $RAILWAY_VOLUME_MOUNT_PATH"
echo "Please update the volume mount path to the expected path and redeploy the service"
exit 1
fi
# Strip trailing slashes from PGDATA. Postgres itself tolerates them, but
# the major-upgrade job builds sibling paths by appending to $PGDATA
# ("${PGDATA}.upgrade-<to>"), so a value with a trailing slash must never be
# allowed to take root on a volume — normalize here exactly like
# upgrade-job.sh does, so both programs agree on every derived path.
while [ "${PGDATA%/}" != "$PGDATA" ] && [ -n "${PGDATA%/}" ]; do PGDATA="${PGDATA%/}"; done
export PGDATA
# check if PGDATA starts with the expected volume mount path
# this ensures data files are stored in the correct location
# if not, print error and exit to prevent data loss or access issues
if [[ ! "$PGDATA" =~ ^"$EXPECTED_VOLUME_MOUNT_PATH" ]]; then
echo "PGDATA variable does not start with the expected volume mount path, expected to start with $EXPECTED_VOLUME_MOUNT_PATH"
echo "Please update the PGDATA variable to start with the expected volume mount path and redeploy the service"
exit 1
fi
# -----------------------------------------------------------------------------
# Volume-lifetime lock shared with the major-upgrade job. upgrade-job.sh
# holds this file EXCLUSIVELY (flock -n) for its whole run; the runtime
# holds it SHARED for the container's lifetime. Together they make both
# races refuse instead of corrupting: a job dispatched against a live
# database fails its exclusive lock, and a database deployed while a job is
# mid-flight fails here. The fd is opened by THIS shell, which stays alive
# for the container's whole lifetime (tini is PID 1 and this shell is its
# only child; docker-entrypoint.sh below is a child of this shell, not an
# exec), so the lock is held exactly as long as the container regardless of
# what postgres does with its inherited copy of the fd — closing a duplicate
# descriptor does not release a flock while the original stays open.
#
# Legacy PGDATA-at-the-volume-root layouts skip the lock on first init:
# creating the lock file inside an empty PGDATA would make docker-entrypoint
# skip initdb (it checks `ls -A`), and that layout can't take an in-place
# upgrade anyway (no sibling slot on the volume for the new data dir).
#
# The file was named .railway-major-upgrade.lock until the rename below:
# a name that reads as an event marker, plus a ctime refreshed by whichever
# boot first recreated it, kept being cited during data-loss forensics as
# evidence that an automatic major upgrade had run — when every boot of
# every volume creates it. The lock now lives at a neutral name and carries
# a self-describing note; the legacy path is still locked (below) whenever
# it exists, so mixed-build runtime/job pairings keep excluding each other,
# but it is never created here again.
# -----------------------------------------------------------------------------
UPGRADE_LOCK_FILE="$EXPECTED_VOLUME_MOUNT_PATH/.railway-volume.lock"
LEGACY_UPGRADE_LOCK_FILE="$EXPECTED_VOLUME_MOUNT_PATH/.railway-major-upgrade.lock"
if command -v flock >/dev/null 2>&1 && [ -d "$EXPECTED_VOLUME_MOUNT_PATH" ] \
&& ! { [ "$PGDATA" = "$EXPECTED_VOLUME_MOUNT_PATH" ] && [ ! -f "$PGDATA/PG_VERSION" ]; }; then
# The 2>/dev/null MUST be scoped by the brace group, never put on the exec
# itself: redirections on a bare `exec` are permanent for the shell, so
# `exec 8>>file 2>/dev/null` would silence stderr for THIS SHELL AND
# EVERYTHING IT SPAWNS — postgres's entire log stream and pgBackRest's
# archive errors would vanish from `docker logs`. The brace group scopes
# the stderr redirect to the open attempt; the fd opened by exec is
# permanent either way.
if { exec 8>>"$UPGRADE_LOCK_FILE"; } 2>/dev/null; then
if ! flock -n -s 8; then
echo "A major version upgrade job is currently running against this volume."
echo "The database must not start until it finishes; retry the deploy once the upgrade completes."
exit 1
fi
# Self-describing, written once while the lock is held: bare lock files
# keep getting read as event markers during forensics on a dead volume.
[ -s "$UPGRADE_LOCK_FILE" ] || printf '%s\n' \
"Advisory flock rendezvous between the Postgres container and Railway maintenance jobs." \
"Created on every boot; its presence is not a record of any upgrade or other event." \
>>"$UPGRADE_LOCK_FILE" 2>/dev/null || true
else
echo "wrapper: could not open $UPGRADE_LOCK_FILE; continuing without the upgrade lock" >&2
fi
# Transition: builds from before the rename rendezvous on the legacy path,
# so contend there too whenever the file exists — an upgrade job from an
# earlier build must still refuse to run against this live database, and
# this boot must still refuse while such a job is mid-flight. if-exists
# only: old volumes already carry the file (every old boot created it);
# a volume that never booted an old build has no old-build peer to
# exclude, and never creating it here is the point of the rename.
# fd 7, not 10+: bash reserves fds >= 10 to save/restore the shell's own
# fds around redirections (e.g. this brace group's 2>/dev/null), and that
# bookkeeping closes a user-opened fd 10 on the way out — flock then sees
# EBADF and every boot on a legacy-carrying volume refuses. Single-digit
# fds are never touched by it.
if [ -f "$LEGACY_UPGRADE_LOCK_FILE" ]; then
if { exec 7>>"$LEGACY_UPGRADE_LOCK_FILE"; } 2>/dev/null; then
if ! flock -n -s 7; then
echo "A major version upgrade job is currently running against this volume."
echo "The database must not start until it finishes; retry the deploy once the upgrade completes."
exit 1
fi
[ -s "$LEGACY_UPGRADE_LOCK_FILE" ] || printf '%s\n' \
"Legacy name of .railway-volume.lock (see that file). Older image builds create this" \
"file on EVERY boot; its presence is not evidence that a major version upgrade ran." \
>>"$LEGACY_UPGRADE_LOCK_FILE" 2>/dev/null || true
else
echo "wrapper: could not open $LEGACY_UPGRADE_LOCK_FILE; continuing without the legacy upgrade lock" >&2
fi
fi
fi
# -----------------------------------------------------------------------------
# Runtime lock: at most one postgres container touches this volume at a time.
# Held EXCLUSIVELY (fd 9) for the container's lifetime, same shell-lives-as-
# long-as-the-container mechanics as the upgrade lock above — the kernel
# releases it only when the
# last process holding the open description exits, so "lock free" really
# means every process of the previous container is gone, however that
# container ended (graceful stop, SIGKILL, OOM).
#
# Why: a redeploy can leave the old and new containers briefly overlapping
# on the shared volume. The stale-postmaster.pid removal below cannot see
# the old container's postgres (different PID namespace), and worse, a
# still-shutting-down old postmaster unlinks postmaster.pid on its way out
# — deleting the file the NEW postgres just wrote. Postgres treats a missing
# lock file as fatal on its once-per-minute recheck and shuts itself down.
# Waiting on the previous holder before touching the data directory closes
# both directions of that race.
#
# Fail-stop on timeout: refusing to boot (the restart policy retries) beats
# starting a second postmaster against a volume the previous one may still
# be using. Same legacy-layout skip as the upgrade lock: never create files
# inside an empty PGDATA-at-the-volume-root, or docker-entrypoint skips
# initdb.
# -----------------------------------------------------------------------------
RUNTIME_LOCK_FILE="$EXPECTED_VOLUME_MOUNT_PATH/.railway-postgres-runtime.lock"
RUNTIME_LOCK_WAIT_SECONDS="${RUNTIME_LOCK_WAIT_SECONDS:-300}"
# Must be a whole number of seconds: `flock -w` rejects anything else with a
# usage error, whose non-zero exit is indistinguishable from a hold timeout
# below — so a typo'd override would surface as "previous container did not
# release the volume" and instant-fail every boot DURING a real overlap,
# exactly when the wait matters. Fall back loudly instead.
case "$RUNTIME_LOCK_WAIT_SECONDS" in
''|*[!0-9]*)
echo "wrapper: RUNTIME_LOCK_WAIT_SECONDS='${RUNTIME_LOCK_WAIT_SECONDS}' is not a whole number of seconds; using 300" >&2
RUNTIME_LOCK_WAIT_SECONDS=300
;;
esac
if command -v flock >/dev/null 2>&1 && [ -d "$EXPECTED_VOLUME_MOUNT_PATH" ] \
&& ! { [ "$PGDATA" = "$EXPECTED_VOLUME_MOUNT_PATH" ] && [ ! -f "$PGDATA/PG_VERSION" ]; }; then
# Brace-group-scoped stderr for the same reason as the upgrade lock above.
if { exec 9>>"$RUNTIME_LOCK_FILE"; } 2>/dev/null; then
if ! flock -n -x 9; then
echo "wrapper: another postgres container still holds this volume (overlapping deploy); waiting up to ${RUNTIME_LOCK_WAIT_SECONDS}s for it to shut down"
if ! flock -w "$RUNTIME_LOCK_WAIT_SECONDS" -x 9; then
echo "wrapper: previous container did not release the volume within ${RUNTIME_LOCK_WAIT_SECONDS}s; refusing to start postgres on a volume another postmaster may still be using"
exit 1
fi
echo "wrapper: previous container released the volume; continuing boot"
fi
else
echo "wrapper: could not open $RUNTIME_LOCK_FILE; continuing without the runtime lock" >&2
fi
fi
# -----------------------------------------------------------------------------
# Major-version guards. Both are fail-stop and loud, and both run before
# anything touches the data directory, so a mismatched boot can never corrupt
# data or initdb over a half-swapped upgrade.
#
# 1. A major-upgrade marker (written by the upgrade job image) in any phase
# other than "completed" means the volume is mid-upgrade: the data
# directory may be absent or half-swapped. Refuse to boot; the upgrade
# workflow resolves it (roll forward or roll back), never this container.
# 2. On-disk PG_VERSION must match this image's major. Postgres itself would
# refuse anyway, but deep in startup with a confusing error; failing here
# names the actual problem and the fix. A fresh volume (no PG_VERSION)
# skips the check — that's first init.
# -----------------------------------------------------------------------------
UPGRADE_MARKER_FILE="$EXPECTED_VOLUME_MOUNT_PATH/.railway-major-upgrade.json"
if [ -f "$UPGRADE_MARKER_FILE" ]; then
# `|| true` so an unreadable marker still lands in the fail-stop branch
# below instead of tripping set -e with no message.
MARKER_PHASE=$(jq -r '.phase // empty' "$UPGRADE_MARKER_FILE" 2>/dev/null || true)
if [ "$MARKER_PHASE" != "completed" ]; then
echo "A major version upgrade is in progress on this volume (marker phase: ${MARKER_PHASE:-unreadable})."
echo "The database must not start until the upgrade workflow finishes or rolls back."
exit 1
fi
fi
# The marker above catches every phase pg_upgrade writes one for — but the
# upgrade job only writes its FIRST marker on pg_upgrade's own success, so a
# crash DURING pg_upgrade --link leaves no marker at all. pg_upgrade renames
# global/pg_control to .old before it links the first relation file (its own
# recovery advice is to rename it back), so this exact shape — .old present,
# the real file absent — means a link was interrupted. PG_VERSION at the
# PGDATA root is untouched by that rename, so it still matches THIS image's
# major and the version-mismatch guard below would not catch it either;
# without this check postgres would fail deep in startup with a bare "could
# not open file "global/pg_control"" instead of naming the actual cause.
if [ -f "$PGDATA/global/pg_control.old" ] && [ ! -f "$PGDATA/global/pg_control" ]; then
echo "This looks like an interrupted major version upgrade: pg_control is disabled (renamed to pg_control.old),"
echo "the shape pg_upgrade leaves if it crashes mid-link. A database major-version-upgrade job resolves this"
echo "volume; starting postgres against it directly will fail."
exit 1
fi
# Belt-and-suspenders for a crash between finish_swap's two renames: PGDATA
# itself is gone at that point (renamed to .old-<from>), so neither guard
# above has anything to check — the pg_control.old one above looks under
# $PGDATA, which no longer exists. No legitimate first init ever has
# upgrade-job sibling directories sitting next to an empty/missing PGDATA;
# only a job that started swapping directories and never finished does.
# Without this, docker-entrypoint would initdb a fresh empty cluster over
# what's actually a split, unfinished upgrade sitting in those siblings.
if [ ! -f "$PGDATA/PG_VERSION" ] \
&& { compgen -G "${PGDATA}.upgrade-*" >/dev/null 2>&1 || compgen -G "${PGDATA}.old-*" >/dev/null 2>&1; }; then
echo "PGDATA is empty or missing, but upgrade-job sibling directories exist next to it"
echo "(${PGDATA}.upgrade-* / ${PGDATA}.old-*) — this looks like an interrupted major version"
echo "upgrade caught mid-swap, not a fresh volume. A database major-version-upgrade job"
echo "resolves this volume; starting postgres against it directly would initdb a new, empty"
echo "cluster over real data sitting in those siblings."
exit 1
fi
# The image's own major, for the mismatch guard. Filesystem first, PG_MAJOR
# env second — deliberately in that order (mirrors postgres-ha's
# image_major()): the installed server tree under /usr/lib/postgresql is
# baked into the image and nothing at deploy time can change it, while
# PG_MAJOR is an env var a service variable can override — trusted alone, a
# stray user-set PG_MAJOR=16 on a 17 image would refuse every boot of a
# perfectly matched data directory. Exactly one numeric entry = the image's
# major; zero or several (never true for runtime images) falls back to the
# env; neither = the guard abstains.
detect_image_major() {
local d name majors=()
for d in /usr/lib/postgresql/*/; do
[ -d "$d" ] || continue
name="${d%/}"; name="${name##*/}"
case "$name" in ''|*[!0-9]*) continue ;; esac
majors+=("$name")
done
if [ "${#majors[@]}" -eq 1 ]; then
if [ -n "${PG_MAJOR:-}" ] && [ "$PG_MAJOR" != "${majors[0]}" ]; then
echo "wrapper: PG_MAJOR=${PG_MAJOR} disagrees with the installed server tree (${majors[0]}); trusting the filesystem — a service variable can override the env, not the image contents" >&2
fi
echo "${majors[0]}"
return 0
fi
echo "${PG_MAJOR:-}"
}
IMAGE_MAJOR=$(detect_image_major)
if [ -f "$PGDATA/PG_VERSION" ]; then
DATA_MAJOR=$(cat "$PGDATA/PG_VERSION")
if [ -n "$IMAGE_MAJOR" ] && [ "$DATA_MAJOR" != "$IMAGE_MAJOR" ]; then
echo "This image runs PostgreSQL $IMAGE_MAJOR but the data directory holds major version $DATA_MAJOR."
echo "Changing the image tag does not upgrade the data files. Set the image back to postgres $DATA_MAJOR,"
echo "or run a major version upgrade from the service's settings."
exit 1
fi
fi
# Set up needed variables
SSL_DIR="/var/lib/postgresql/data/certs"
INIT_SSL_SCRIPT="/docker-entrypoint-initdb.d/init-ssl.sh"
POSTGRES_CONF_FILE="$PGDATA/postgresql.conf"
# H1 (audit follow-up): clear stale postmaster.pid. wrapper.sh runs once
# at container start — no postgres has been spawned yet — so any
# postmaster.pid on disk is from a previous container that didn't get a
# graceful shutdown (docker rm -f sends SIGKILL; the kernel reaps
# postgres before it can remove its pid file).
#
# Postgres normally self-heals: on start it reads postmaster.pid, calls
# kill(pid, 0), and removes the file if the PID is dead. The trouble in
# a container is that postgres ALSO checks if the PID belongs to the
# same UID — and the watcher's psql/pg_isready subprocesses run as the
# same postgres UID, so the stale PID number often points at a live
# (unrelated) watcher subprocess. postgres reads same-UID + alive →
# concludes "another postmaster is already running" → FATAL.
#
# Removing the file unconditionally here is safe because (a) wrapper.sh
# is the container entrypoint and runs exactly once per container, (b)
# no postgres process is running at this point, and (c) docker
# guarantees only one wrapper.sh per container instance. Avoids the
# need for a graceful-shutdown handoff that re-parents children onto
# postmaster (postmaster panics with SIGCHLD on unknown children).
#
# "No postgres running" additionally covers OTHER containers on this
# volume once the runtime lock (fd 9, above) is held: any previous
# container running this image keeps that lock until its last process
# exits, so reaching this line means no lock-honoring postmaster can
# still be alive on the volume. A previous container from an image
# WITHOUT the lock can still overlap; the unasked-clean-exit shaping at
# the bottom of this file is what recovers that case.
if [ -f "$PGDATA/postmaster.pid" ]; then
echo "wrapper: removing stale $PGDATA/postmaster.pid (no postgres running at container start)"
rm -f "$PGDATA/postmaster.pid" 2>/dev/null || true
fi
# Regenerate if the certificate is not a x509v3 certificate
if [ -f "$SSL_DIR/server.crt" ] && ! openssl x509 -noout -text -in "$SSL_DIR/server.crt" | grep -q "DNS:localhost"; then
echo "Did not find a x509v3 certificate, regenerating certificates..."
bash "$INIT_SSL_SCRIPT"
fi
# Regenerate if the certificate has expired or will expire
# 2592000 seconds = 30 days
if [ -f "$SSL_DIR/server.crt" ] && ! openssl x509 -checkend 2592000 -noout -in "$SSL_DIR/server.crt"; then
echo "Certificate has or will expire soon, regenerating certificates..."
bash "$INIT_SSL_SCRIPT"
fi
# Generate a certificate if the database was initialized but is missing a certificate
# Useful when going from the base postgres image to this ssl image
if [ -f "$POSTGRES_CONF_FILE" ] && [ ! -f "$SSL_DIR/server.crt" ]; then
echo "Database initialized without certificate, generating certificates..."
bash "$INIT_SSL_SCRIPT"
fi
# Re-apply the ssl settings when the certificates exist but postgresql.conf
# doesn't reference them. The checks above are keyed on the CERTIFICATE, which
# lives at the volume root and therefore survives anything that replaces the
# data directory — a major upgrade promotes a freshly initdb'd $PGDATA, so
# postgresql.conf loses `ssl = on` while server.crt is still right there. The
# result is a database that silently comes back with SSL off, rejecting every
# sslmode=require client. Keying this on the CONFIG instead self-heals that
# and any other path that resets postgresql.conf.
if [ -f "$POSTGRES_CONF_FILE" ] && [ -f "$SSL_DIR/server.crt" ] \
&& ! grep -qE "^[[:space:]]*ssl[[:space:]]*=" "$POSTGRES_CONF_FILE"; then
echo "wrapper: postgresql.conf has no ssl settings but certificates exist, re-applying"
cat >> "$POSTGRES_CONF_FILE" <<EOF
ssl = on
ssl_cert_file = '$SSL_DIR/server.crt'
ssl_key_file = '$SSL_DIR/server.key'
ssl_ca_file = '$SSL_DIR/root.crt'
EOF
# Flush the append: a block-level volume snapshot taken before writeback
# would capture the new size with zeroed data blocks (torn conf on restore).
sync "$POSTGRES_CONF_FILE"
fi
# Re-append the remote-access rule when pg_hba.conf has been reset to
# initdb's defaults. The official entrypoint writes `host all all all
# <method>` ONCE, at initdb time — any path that regenerates pg_hba.conf
# afterwards (a major upgrade promotes a freshly initdb'd data directory;
# the upgrade job carries the old pg_hba across, but this heals every other
# route too) leaves only initdb's default local/loopback lines, so the
# database comes back healthy-looking while EVERY remote client fails with
# "no pg_hba.conf entry". The method mirrors the entrypoint's default.
#
# The heal fires ONLY on the recognizable bare-initdb shape: loopback host
# rules present, and no host-family rule with any other address. Anything
# else is an authored policy and is left alone — a config the operator
# narrowed by address (`host all all 10.0.0.0/8 …`), by database/user
# (`host mydb appuser all …`), or by TLS (`hostssl …`) must never get a
# wide-open `host all all all` silently appended under it: pg_hba is
# first-match-wins, so the append would re-admit exactly the clients the
# narrowing excluded. Likewise a file with NO host rules at all is a
# deliberate local-only lockdown, not an initdb reset (initdb always writes
# the loopback lines).
PG_HBA_FILE="$PGDATA/pg_hba.conf"
hba_has_loopback_host_rule() {
grep -qE "^[[:space:]]*host([[:space:]]+[^[:space:]]+){2}[[:space:]]+(127\.0\.0\.1/32|::1/128)([[:space:]]|$)" "$PG_HBA_FILE"
}
hba_has_authored_host_rule() {
grep -E "^[[:space:]]*host(ssl|nossl|gssenc|nogssenc)?[[:space:]]" "$PG_HBA_FILE" \
| grep -vE "[[:space:]](127\.0\.0\.1/32|::1/128)([[:space:]]|$)" | grep -q .
}
if [ -f "$POSTGRES_CONF_FILE" ] && [ -f "$PG_HBA_FILE" ] \
&& hba_has_loopback_host_rule && ! hba_has_authored_host_rule; then
echo "wrapper: pg_hba.conf holds only initdb's loopback host rules, re-appending the remote-access rule (remote clients would be refused)"
cat >> "$PG_HBA_FILE" <<EOF
host all all all ${POSTGRES_HOST_AUTH_METHOD:-scram-sha-256}
EOF
# Flush the append: same torn-tail-on-snapshot hazard as postgresql.conf.
sync "$PG_HBA_FILE"
fi
# Adds pg_stat_statements to shared_preload_libraries in a config file
# Usage: add_pg_stat_statements <config_file>
add_pg_stat_statements() {
local config_file="$1"
local current_libs
# Extract value - handles quoted ('val', "val") and unquoted (val) formats
current_libs=$(grep -E "^[[:space:]]*shared_preload_libraries" "$config_file" 2>/dev/null | tail -1 | sed "s/.*=[[:space:]]*//; s/^['\"]//; s/['\"].*$//; s/[[:space:]]*$//")
if [ -n "$current_libs" ]; then
echo "shared_preload_libraries = '${current_libs},pg_stat_statements'" >> "$config_file"
else
echo "shared_preload_libraries = 'pg_stat_statements'" >> "$config_file"
fi
}
# Ensure pg_stat_statements is in shared_preload_libraries for existing databases
# This handles databases created before this setting was added
AUTO_CONF_FILE="$PGDATA/postgresql.auto.conf"
# Raise max_connections so PgBouncer (3 replicas × default_pool_size=20 = 60
# backend connections) doesn't exhaust the PostgreSQL default of 100. Writing
# to auto.conf overrides postgresql.conf without touching the file Postgres
# manages itself. Skipped if already set (e.g. via ALTER SYSTEM by the user).
if [ -f "$POSTGRES_CONF_FILE" ] && ! grep -q "^[[:space:]]*max_connections" "$AUTO_CONF_FILE" 2>/dev/null; then
echo "wrapper: setting max_connections = 500 in postgresql.auto.conf"
echo "max_connections = 500" >> "$AUTO_CONF_FILE"
fi
if [ -f "$POSTGRES_CONF_FILE" ] && ! grep -q "pg_stat_statements" "$POSTGRES_CONF_FILE"; then
echo "Adding pg_stat_statements to shared_preload_libraries..."
add_pg_stat_statements "$POSTGRES_CONF_FILE"
# Only update auto.conf if it has shared_preload_libraries set (which would override postgresql.conf)
# and doesn't already have pg_stat_statements
if grep -q "^[[:space:]]*shared_preload_libraries" "$AUTO_CONF_FILE" 2>/dev/null && ! grep -q "pg_stat_statements" "$AUTO_CONF_FILE" 2>/dev/null; then
add_pg_stat_statements "$AUTO_CONF_FILE"
fi
fi
# -----------------------------------------------------------------------------
# WAL archiving + PITR (tool-agnostic env contract)
#
# Backboard / frontend / template speak `WAL_ARCHIVE_*` (this service's own
# destination bucket — write-only for this service) and `WAL_RECOVER_FROM_*`
# (only on PITR-restored forks, points at source's bucket for read-during-
# recovery). Neither is exported as PGBACKREST_REPO*_*: pgBackRest's option
# resolution is command-line > env vars > config file > defaults, so a global
# REPO1_* export silently overrides any --config we pass during recovery.
#
# Instead, we materialise two non-overlapping config files and route every
# pgbackrest invocation through one of them via --config:
#
# /etc/pgbackrest/pgbackrest.conf — rendered by render_pgbackrest_conf.
# Has the service's own archive bucket as repo1. archive_command,
# stanza-create, and the watcher's backup all read this and only this.
#
# /etc/pgbackrest/pgbackrest-recovery-source.conf — written by
# restore_from_pgbackrest_if_empty_volume and re-rendered by
# configure_pgbackrest_recovery on every boot when WAL_RECOVER_FROM_*
# is set. Has source's read-only bucket as repo1 (numbering is per-
# config). The persisted restore_command in postgresql.auto.conf
# references this file via --config, so archive-get during recovery
# reads source's bucket without leaking into archive_command.
#
# This isolation is load-bearing: pgBackRest 2.58's archive-push fans out
# to every configured repo with no per-call scoping. A fork that has both
# its own bucket and source's bucket configured in the same pgbackrest
# would push every WAL to source's read-only bucket, fail with 403, and
# silently degrade the new service's PITR window.
#
# The watcher and archive-push wrapper set PGBACKREST_REPO1_PATH locally
# from the .pgbackrest_repo_path marker — that's a per-call override of
# the path within the service's own repo1, not a cross-repo conflict.
# Helpers gate on whichever role is active. WAL_ARCHIVE_BUCKET gates the
# archiving path (rendering /etc/pgbackrest/pgbackrest.conf, archive_mode=on,
# archive_command, the watcher, stanza-create); WAL_RECOVER_FROM_BUCKET +
# POSTGRES_RECOVERY_TARGET_TIME together gate arming archive recovery on
# first boot.
#
# Postgres config is delivered via a managed include directory (conf.d/)
# rather than postgresql.auto.conf. ALTER SYSTEM rewrites auto.conf and
# strips comments, so any sentinel-bracketed approach there is fragile.
# postgresql.conf is not rewritten by Postgres at runtime, so adding a
# one-time `include_dir = 'conf.d'` directive is durable; from then on,
# enable/disable is just write/remove of conf.d/pgbackrest.conf. conf.d
# loads before auto.conf so a determined operator's `ALTER SYSTEM SET
# archive_mode = 'off'` (or `ALTER SYSTEM SET track_commit_timestamp = 'off'`,
# which silently degrades the PITR picker's `recovery_target_time` ceiling
# to lastArchivedAt) still wins; the dashboard surfaces the divergence
# from the image's intended state by reading pg_settings.
#
# pgBackRest pushes WAL direct to S3 (no intermediary service). It runs in
# async mode: archive_command writes WAL into the local spool dir and
# returns in milliseconds; a background worker pushes from there to S3.
# Two thresholds gate the "WAL is accumulating, do something", sized
# identically (see compute_volume_thresholds) AND checked as ONE shared
# budget, not two independent ones:
# - archive-push-queue-max (set in /etc/pgbackrest/pgbackrest.conf) governs
# the SPOOL. pgBackRest drops segments from spool and reports success to
# archive_command once this is exceeded.
# - pgbackrest-archive-push-wrapper.sh's WAL_DROP_THRESHOLD_MB governs
# pg_wal/ + spool COMBINED. Trips when pgbackrest's foreground returns
# non-zero and pg_wal-plus-spool has grown past this size.
# Both size to 5 GiB on volumes ≥10 GiB, scaling down to ~50% of volume below
# that, floor 128 MiB — a transient S3 stall (500s, timeouts, connection
# resets — the async worker keeps retrying and most segments eventually get
# pushed) gets the full budget before either threshold trips, instead of the
# wrapper's old 10x-smaller pg_wal-only cap giving up first. Checking the
# wrapper's threshold against the SUM (not pg_wal alone) matters because
# pg_wal and the spool can both be filling for different reasons at once
# (foreground copy-to-spool failing vs. background upload stalled) — without
# summing, the two caps could each independently reach 5 GiB, letting a
# single outage hold up to ~2x the intended budget on disk. Only the two
# explicit no-recovery-possible errors (NoSuchBucket, InvalidAccessKeyId —
# see pgbackrest-archive-push-wrapper.sh) bypass this and drop immediately,
# since no amount of waiting helps those.
# Either way, PITR window truncates; DB stays up.
# -----------------------------------------------------------------------------
PGBACKREST_CONF_FILE="/etc/pgbackrest/pgbackrest.conf"
# Dedicated config holding repo2 (= source bucket) settings for archive-get
# during recovery only. Lives under /etc/pgbackrest so the default conf
# never has repo2 — archive_command + stanza-create read only the default
# and can't fan out to source's read-only bucket.
PGBACKREST_RECOVERY_S3_CONF="/etc/pgbackrest/pgbackrest-recovery-source.conf"
PGBACKREST_CONFD_DIR="$PGDATA/conf.d"
PGBACKREST_ARCHIVE_CONF="$PGBACKREST_CONFD_DIR/pgbackrest.conf"
PGBACKREST_RECOVERY_CONF="$PGBACKREST_CONFD_DIR/pgbackrest-recovery.conf"
# Companion to the empty-volume restore path: `pgbackrest restore` writes the
# recovery params into postgresql.auto.conf itself (so the conf.d recovery
# file above is never staged on that path), but it does NOT turn hot_standby
# off — leaving the replaying fork readable at the base-backup state before
# redo reaches the target. This one-line conf.d file closes that window; it
# is removed by configure_pgbackrest_recovery once recovery is genuinely done
# (post-promote), and by clear_pgbackrest_state_if_disabled when the recovery
# role is dropped.
PGBACKREST_RESTORE_STANDBY_CONF="$PGBACKREST_CONFD_DIR/pgbackrest-restore-hot-standby.conf"
PGBACKREST_SPOOL_DIR="$PGDATA/pgbackrest-spool"
# PITR staging stamps. .pitr_staging is written when a replay is handed off
# to Postgres; .pitr_configured is written on the boot AFTER Postgres consumes
# recovery.signal (i.e., promote succeeded), so subsequent boots skip
# re-arming recovery. Source-bucket / repo-path divergence checks are gone:
# under the new-service restore design, the restored service has its own
# bucket (`WAL_ARCHIVE_*`) and reads from the source's bucket via the
# distinct `WAL_RECOVER_FROM_*` repo, so no shared write path exists.
PITR_STAGING_FILE="$PGDATA/.pitr_staging"
PITR_DONE_MARKER="$PGDATA/.pitr_configured"
# Written by restore_from_pgbackrest_if_empty_volume after a successful
# `pgbackrest restore` populates an empty volume. Tells configure_pgbackrest_recovery
# to bail — pgbackrest restore already wrote recovery.signal + recovery params,
# our conf.d/pgbackrest-recovery.conf path would duplicate them.
PGBACKREST_RESTORED_MARKER="$PGDATA/.pgbackrest_restored"
# Restartpoint tuning applied ONLY to a replay boot, via `-c` flags on the
# postgres invocation itself — never written to postgresql.conf/auto.conf.
# Without this, pg_wal can accumulate WAL segments fetched by restore_command
# until the next restartpoint, which fires at whatever cadence
# checkpoint_timeout/max_wal_size already on disk allow (vanilla defaults,
# a customer's own prod tuning on an existing volume, or — on a volume
# cloned from the source at the block level — the source's tuning). On a
# volume provisioned at ~the source's current size, that buffer risks
# ENOSPC before recovery ever reaches the PITR target. This is deliberately a
# best-effort reduction, not a hard disk bound: recovery can only perform a
# restartpoint at a checkpoint record written by the source, so PostgreSQL may
# exceed max_wal_size by as much as one source checkpoint cycle. Operators must
# still provision that headroom. `-c` wins over
# auto.conf regardless of what's already on disk and applies to this one
# process only, so there's nothing to revert: the next boot (post-promote,
# once recovery.signal is gone) just uses the Dockerfile CMD's plain args.
# Safe to be aggressive — this boot serves no live traffic, so the extra
# checkpoint overhead only extends replay time, not query latency.
PITR_RECOVERY_CHECKPOINT_TIMEOUT="${PITR_RECOVERY_CHECKPOINT_TIMEOUT:-30s}"
PITR_RECOVERY_MAX_WAL_SIZE="${PITR_RECOVERY_MAX_WAL_SIZE:-512MB}"
# Per-cluster archive sub-path: the effective repo1-path, persisted inside
# PGDATA so the watcher, archive-push wrapper, and stanza-create subshell
# all converge on the same value. Per-cluster pathing means a wipe-and-
# reuse-bucket cycle (volume wiped, container redeployed against the same
# WAL_ARCHIVE_BUCKET) lets the new cluster's history coexist with the old
# at distinct sub-prefixes — no system-id collision, no orphaned data, no
# silent overwrite. Mono surfaces all sub-paths as separate "histories"
# the user can browse and restore from.
PGBACKREST_REPO_PATH_MARKER="$PGDATA/.pgbackrest_repo_path"
# Identity fingerprint of the cluster the repo-path marker was derived from
# (`sysid=` + `pg_version=`), written next to the marker every time the marker
# is derived. The marker on its own cannot say whether it still belongs to the
# cluster on disk — it is a bare path that wins VERBATIM over derivation, by
# design, so that the effective repo path is stable across boots. When the
# cluster underneath it is REPLACED (pg_upgrade initdb's a new cluster: new
# system_identifier and new PG_VERSION), a marker that outlives the swap keeps
# archiving into the previous cluster's repo — archive-push then fails against
# that repo's archive.info forever and stanza-create errors on the system-id
# mismatch. Comparing this fingerprint against the live values on every boot
# catches that whatever the cause, which is the point: the major-upgrade
# marker is deleted/aged out eventually, and a cluster re-identified by any
# other route needs exactly the same handling.
#
# Holds no path on purpose. The marker is the sole authority on the active
# path, so there is nothing here that can drift out of sync with it — and a
# WAL_REGRESSION migration (which re-points the path of a cluster whose
# identity did NOT change) must not look like a re-identification.
PGBACKREST_REPO_ANCHOR_FILE="$PGDATA/.pgbackrest_repo_anchor"
# Sentinel: WAL_ARCHIVE_BUCKET was set to something we couldn't honor (an
# unresolved Railway template ref, a bucket-id UUID, whitespace, …). The
# monitor reads this to distinguish "PITR was never enabled" from "PITR is
# enabled but wired to junk." Lives under PGDATA so it survives container
# restarts and gets wiped with the volume.
PGBACKREST_INVALID_BUCKET_MARKER="$PGDATA/.pgbackrest_invalid_bucket"
# Screen WAL_ARCHIVE_BUCKET for known-bogus shapes before any code path
# consumes it. If invalid, unset the WAL_ARCHIVE_* vars so every existing
# `[ -z "${WAL_ARCHIVE_BUCKET:-}" ]` gate downstream treats archiving as
# off — same behavior as "never enabled." Without this, an unresolved
# `${{<bucket-id>.BUCKET}}` would land in /etc/pgbackrest/pgbackrest.conf
# verbatim; pgBackRest would then hard-fail every archive_command and
# pgbackrest-archive-push-wrapper.sh's 500 MiB WAL-drop threshold would
# eventually trip — creating a real, unrecoverable PITR gap from what is
# really an upstream wiring bug. The sentinel file lets the dashboard
# surface this state distinctly from the no-config and unresolvable-creds
# states.
#
# Caught shapes:
# - empty → already handled by existing gates; no-op here.
# - contains `${{` or `}}` → unresolved Railway template ref.
# - UUID 8-4-4-4-12 hex → almost certainly a raw bucket-id from a
# tombstoned bucket (resolver failed to map id → name).
# - whitespace or control chars → typo or shell-escape mishap.
validate_wal_archive_bucket() {
local val="${WAL_ARCHIVE_BUCKET:-}"
[ -z "$val" ] && { rm -f "$PGBACKREST_INVALID_BUCKET_MARKER" 2>/dev/null || true; return 0; }
local invalid=""
case "$val" in
*'${{'*|*'}}'*) invalid="unresolved-template-ref" ;;
*[[:space:]]*) invalid="whitespace" ;;
esac
# UUID-shape rejection: catches Railway internal bucket-id leaks from a
# failed resolver. WAL_ARCHIVE_BUCKET_ALLOW_UUID=1 is the escape hatch for
# the rare customer who legitimately uses a UUID-named bucket.
if [ -z "$invalid" ] \
&& [ "${WAL_ARCHIVE_BUCKET_ALLOW_UUID:-0}" != "1" ] \
&& echo "$val" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then
invalid="uuid-shape"
fi
if [ -n "$invalid" ]; then
if [ "$invalid" = "uuid-shape" ]; then
echo "pgbackrest: WAL_ARCHIVE_BUCKET=\"${val}\" looks invalid (uuid-shape); refusing to enable archiving. If this UUID is your legitimate bucket name, set WAL_ARCHIVE_BUCKET_ALLOW_UUID=1 to override." >&2
else
echo "pgbackrest: WAL_ARCHIVE_BUCKET=\"${val}\" looks invalid (${invalid}); refusing to enable archiving" >&2
fi
# Export so pgbackrest-init.sh can write the sentinel during initdb.
# Writing to PGDATA here would break initdb on a fresh volume:
# docker-entrypoint.sh skips initdb when `ls -A "$PGDATA"` is non-empty,
# even for hidden files — postgres then tries to start from uninitialized
# data and fails. On an already-initialized volume (PG_VERSION exists)
# initdb hooks don't run, so write the sentinel here instead.
export PGBACKREST_BUCKET_INVALID_REASON="$invalid"
if [ -f "$PGDATA/PG_VERSION" ]; then
printf '%s\n' "${invalid}" > "$PGBACKREST_INVALID_BUCKET_MARKER" 2>/dev/null || true
chown postgres:postgres "$PGBACKREST_INVALID_BUCKET_MARKER" 2>/dev/null || true
chmod 0640 "$PGBACKREST_INVALID_BUCKET_MARKER" 2>/dev/null || true
fi
unset WAL_ARCHIVE_BUCKET WAL_ARCHIVE_KEY WAL_ARCHIVE_SECRET
unset WAL_ARCHIVE_REGION WAL_ARCHIVE_ENDPOINT
return 0
fi
rm -f "$PGBACKREST_INVALID_BUCKET_MARKER" 2>/dev/null || true
}
validate_wal_archive_bucket
# Add `include_dir = 'conf.d'` to postgresql.conf if not already present.
# postgresql.conf is not rewritten by Postgres at runtime (only auto.conf is,
# by ALTER SYSTEM), so this single line is durable. Called from both the
# archive-conf write path and the recovery-staging path.
#
# The detection regex accepts single-quoted, double-quoted, and unquoted
# forms because postgresql.conf treats all three as equivalent. Without the
# tolerance, a hand-tuned image (or a future PG release that changes the
# default quoting) would silently get a duplicate include_dir line on every
# boot — Postgres tolerates duplicates (last wins, both point at the same
# dir) but the noise is avoidable.
ensure_pg_includes_confd() {
[ ! -f "$POSTGRES_CONF_FILE" ] && return 0
if grep -qE "^[[:space:]]*include_dir[[:space:]]*=[[:space:]]*['\"]?conf\.d['\"]?[[:space:]]*$" "$POSTGRES_CONF_FILE"; then
return 0
fi
echo "include_dir = 'conf.d'" >> "$POSTGRES_CONF_FILE"
# Flush the append: same torn-tail-on-snapshot hazard as the ssl block.
sync "$POSTGRES_CONF_FILE"
echo "pgbackrest: enabled include_dir 'conf.d' in postgresql.conf"
}
# Read Postgres' system_identifier from pg_control. Empty when pg_control
# isn't on disk yet (fresh volume, pre-initdb).
read_postgres_sysid() {
[ ! -f "$PGDATA/global/pg_control" ] && return 0
pg_controldata "$PGDATA" 2>/dev/null \
| awk -F: '/Database system identifier/ { gsub(/[ \t]/,"",$2); print $2 }'
}
# The cluster's on-disk major. Empty pre-initdb. `|| true` because this file
# runs under `set -e` and an unreadable PG_VERSION must never abort the boot.
read_postgres_major() {
[ ! -f "$PGDATA/PG_VERSION" ] && return 0
cat "$PGDATA/PG_VERSION" 2>/dev/null || true
}
# Field reader for the anchor file. Empty when the file or the field is absent.
# grep sits mid-pipeline so a no-match exits 0 overall — a bare `grep` here
# would abort the boot under `set -e` the first time a field is missing.
read_pgbackrest_anchor_field() {
local field="$1"
[ ! -f "$PGBACKREST_REPO_ANCHOR_FILE" ] && return 0
grep -E "^${field}=" "$PGBACKREST_REPO_ANCHOR_FILE" 2>/dev/null | tail -1 | cut -d= -f2-
}
# Record which cluster the current repo-path marker belongs to. tmp+rename so
# a reader never sees a half-written fingerprint, and so a crash can only ever
# leave the PREVIOUS anchor in place — which is what makes the re-anchor
# retryable (see reanchor_pgbackrest_repo_path_if_reidentified).
write_pgbackrest_repo_anchor() {
local sysid="$1" major="$2" tmp
[ -z "$sysid" ] && return 1
[ -z "$major" ] && return 1
tmp=$(mktemp "${PGBACKREST_REPO_ANCHOR_FILE}.XXXX") || return 1
printf 'sysid=%s\npg_version=%s\n' "$sysid" "$major" > "$tmp" || { rm -f "$tmp"; return 1; }
chown postgres:postgres "$tmp" 2>/dev/null || true
chmod 0640 "$tmp" 2>/dev/null || true
mv "$tmp" "$PGBACKREST_REPO_ANCHOR_FILE" || { rm -f "$tmp"; return 1; }
}
# Resolve the effective repo1-path for archiving:
#
# 1. Marker file present → trust it. Idempotent across boots; survives
# container restarts; wiped with the volume.
# 2. pg_control exists, marker absent → derive
# `${WAL_ARCHIVE_PATH}/cluster-<sysid>`, write marker.
# 3. Pre-initdb (no pg_control) → return `${WAL_ARCHIVE_PATH}` as a
# placeholder; the marker gets written by pgbackrest-init.sh's
# post-initdb hook or by the bootstrap subshell once Postgres is up,
# so subsequent reads converge.
#
# After wipe-and-reuse-bucket, the new cluster (different sysid) gets a
# fresh marker pointing at a fresh `cluster-<new_sysid>` path. The previous
# cluster's data at `cluster-<old_sysid>` is untouched and remains visible
# to the bucket lister (mono UI).
derive_pgbackrest_repo_path() {
local user_path="${WAL_ARCHIVE_PATH:-/pgbackrest}"
if [ -f "$PGBACKREST_REPO_PATH_MARKER" ]; then
cat "$PGBACKREST_REPO_PATH_MARKER"
return 0
fi
local sysid
sysid=$(read_postgres_sysid)
if [ -z "$sysid" ]; then
echo "$user_path"
return 0
fi
local cluster_path="${user_path%/}/cluster-${sysid}"
write_pgbackrest_repo_path_marker "$cluster_path"
# Fingerprint the cluster this path was derived FROM, so a later boot can
# tell whether the marker still belongs to the cluster on disk. Written here
# rather than inside the marker writer: the marker writer is also the flip
# step of a re-anchor, where the anchor must land last, as the commit point.
write_pgbackrest_repo_anchor "$sysid" "$(read_postgres_major)" \
|| echo "pgbackrest: could not write the repo-path anchor for cluster ${sysid}" >&2
echo "$cluster_path"
}
# tmp+rename: the archive-push wrapper `cat`s this file on every WAL switch,
# so a reader must see either the whole old path or the whole new one — same
# reasoning as the watcher's apply_active_path, which is the other writer.
write_pgbackrest_repo_path_marker() {
local path="$1" tmp
[ -z "$path" ] && return 1
tmp=$(mktemp "${PGBACKREST_REPO_PATH_MARKER}.XXXX") || return 1
printf '%s\n' "$path" > "$tmp" || { rm -f "$tmp"; return 1; }
chown postgres:postgres "$tmp" 2>/dev/null || true
chmod 0640 "$tmp" 2>/dev/null || true
mv "$tmp" "$PGBACKREST_REPO_PATH_MARKER" || { rm -f "$tmp"; return 1; }
}
# Replace the backup watcher's state file with nothing but the migration gate
# (`archive_migration_pending_new_path`). Releasing the gate is the separate,
# field-preserving clear_backup_state_migration_gate below — never this.
#
# Replaced rather than edited because every field in it describes backups at
# the OLD path. Clearing last_full_at is what trips the watcher's
# NEEDS_INITIAL_BACKUP, so an immediate full fires at the new path; every other
# field the watcher reads defaults sanely when absent.
#
# The gate itself is the watcher's own migration interlock (see
# pgbackrest-backup-watcher.sh): while set, the watcher takes no backups and
# keeps retrying the spool cleanup. Setting it BEFORE the marker flip means a
# re-anchor that dies halfway can never leave the watcher backing up against
# stale old-path async statuses.
#
# Safe to write wholesale because the watcher is forked strictly after this
# runs — wrapper.sh has no concurrent writer at this point in the boot.
#
# Every call here follows a genuine re-identification (this function's only
# caller, reanchor_pgbackrest_repo_path_if_reidentified, returns early unless
# anchor_sysid/anchor_major mismatched the live cluster) — never the
# watcher's own same-cluster WAL_REGRESSION retries, which manage
# archive_migration_orig_path entirely on their own (read_state /
# write_state_field_required) and never call this function. So any
# archive_migration_orig_path already on disk belongs to the OLD identity's
# suffix chain: carrying it into the new one would compose the new cluster's
# next WAL_REGRESSION migration off the old family (cluster-<OLD
# SYSID>-<epoch>) instead of the new one. Wholesale-replace really does mean
# wholesale — there is no field to preserve.
write_backup_state_migration_gate() {
local new_path="$1" state_file="$PGDATA/.pgbackrest_backup_state" tmp
tmp=$(mktemp "${state_file}.XXXX") || return 1
if ! printf 'archive_migration_pending_new_path=%s\n' "$new_path" > "$tmp"; then
rm -f "$tmp"; return 1
fi
chown postgres:postgres "$tmp" 2>/dev/null || true
chmod 0640 "$tmp" 2>/dev/null || true
mv "$tmp" "$state_file" || { rm -f "$tmp"; return 1; }
}
# Release the watcher's migration gate WITHOUT touching the rest of its
# state. The gate-SET path deliberately replaces the whole file (see
# write_backup_state_migration_gate), but the CLEAR must not: on the
# re-anchor RETRY path (marker already flipped by an earlier attempt whose
# final anchor write failed) the state file holds real post-migration
# watcher state — a last_full_at from a full that already landed at the new
# path — and wiping it would re-arm NEEDS_INITIAL_BACKUP into one redundant
# full upload per boot until the anchor write finally succeeds.
clear_backup_state_migration_gate() {
local state_file="$PGDATA/.pgbackrest_backup_state" tmp
[ ! -f "$state_file" ] && return 0
tmp=$(mktemp "${state_file}.XXXX") || return 1
grep -vE "^archive_migration_pending_new_path=" "$state_file" > "$tmp" 2>/dev/null || true
chown postgres:postgres "$tmp" 2>/dev/null || true
chmod 0640 "$tmp" 2>/dev/null || true
mv "$tmp" "$state_file" || { rm -f "$tmp"; return 1; }
}
# Drop async status files left by the previous cluster. A stale `.ok` is the
# dangerous one: pgBackRest treats it as proof the segment was already pushed
# and skips the upload, so it would silently punch holes in the NEW path's WAL
# coverage. The spool is a coordination cache, never durable data — async
# re-pushes from pg_wal on the next archive_command.
#
# Unlike the watcher's mid-flight equivalent there is no async daemon to drain
# and kill here: wrapper.sh runs exactly once per container start, before any
# postmaster of ours exists, so nothing can be writing these files.
clean_archive_spool_statuses() {
local out_dir="$PGBACKREST_SPOOL_DIR/archive/main/out"
[ ! -d "$out_dir" ] && return 0
rm -f "$out_dir"/*.ok "$out_dir"/*.error 2>/dev/null || true
return 0
}
# Move archiving onto $2, leaving the previous cluster's archive at $1 intact.
# Split out from the detection so the ordering below is readable as a unit; see
# reanchor_pgbackrest_repo_path_if_reidentified for why the order is what it is.
reanchor_pgbackrest_repo_path() {
local current_path="$1" new_path="$2"
# `current_path == new_path` means an earlier attempt already flipped the
# marker and then died before writing the anchor. The state reset always
# PRECEDES the flip, so a flipped marker also implies the state was reset —
# do not redo it (that would re-arm a full for a path that may already have
# one) and just finish the tail.
if [ "$current_path" != "$new_path" ]; then
write_backup_state_migration_gate "$new_path" || {
echo "pgbackrest: could not reset the backup-watcher state; leaving archiving at ${current_path}" >&2
return 1
}
write_pgbackrest_repo_path_marker "$new_path" || {
echo "pgbackrest: could not write the repo-path marker for ${new_path}" >&2
return 1
}
# Defense-in-depth for callers that read the conf instead of the marker
# (operator `docker exec` shells, mono's SSH `pgbackrest info` probe when
# it can't export the env override). bootstrap_pgbackrest_stanza rewrites
# this again from the marker once Postgres is up; both are idempotent.
if [ -f "$PGBACKREST_CONF_FILE" ]; then
sed -i "s|^repo1-path=.*|repo1-path=${new_path}|" "$PGBACKREST_CONF_FILE" \
|| echo "pgbackrest: could not rewrite repo1-path in ${PGBACKREST_CONF_FILE} (the marker is authoritative)" >&2
fi
fi
clean_archive_spool_statuses
# The old cluster's gap sentinel describes the old path's coverage.
rm -f "$PGDATA/.pgbackrest_gap_pending" 2>/dev/null || true
# Cleanup done: release the watcher's backup gate — field-preserving, so
# the retry path's real watcher state survives. Failing here is safe —
# the watcher retries the same cleanup and clears the gate itself.
clear_backup_state_migration_gate \
|| echo "pgbackrest: could not clear the pending-migration gate; the watcher will retry it" >&2
return 0
}
# Re-anchor archiving onto the current cluster's own repo path when the cluster
# that anchored the marker is gone. Runs on EVERY boot, before stanza bootstrap
# and before Postgres starts — the only window where the flip is free: no
# postmaster means no archive_command in flight, and no async daemon of ours
# exists yet.
#
# Detection is the anchor fingerprint, never the major-upgrade marker: a
# pg_upgrade is only the most common way to acquire a new system_identifier,
# the upgrade marker is deleted/aged out eventually, and any other route to a
# re-identified cluster needs the identical response. Today's upgrade job
# reaches the same end state by a different road — its directory swap promotes
# a freshly initdb'd data dir, so the new PGDATA inherits none of the old
# cluster's pgbackrest files and the path is simply DERIVED fresh. This check
# is what keeps that from being load-bearing: any upgrade route that carries
# $PGDATA's config forward (or the dump/restore fallback for the legacy
# PGDATA-is-the-volume-root layout, where these files sit outside the swapped
# directory entirely) hands us a surviving marker pointing at the previous
# cluster's repo.
#
# The new path needs no uniqueness suffix — unlike the watcher's WAL_REGRESSION
# migration, which re-points the path of a cluster whose identity did NOT
# change and therefore has to disambiguate with an epoch. A fresh
# system_identifier makes `cluster-<sysid>` collision-free by construction,
# and deterministic: a crashed attempt recomputes exactly the same target
# instead of stranding a sibling prefix per retry.
#
# Order is chosen so every interruption converges on the next boot:
# 1. gate the watcher + clear its timestamps (state describes the old path)
# 2. flip the marker (+ conf) — the instant archiving moves
# 3. drop the old path's spool statuses, release the watcher gate
# 4. write the new anchor LAST
# The anchor is the commit point: while it still names the old cluster the next
# boot re-detects and re-runs, and every step is idempotent. Nothing here can
# fail the boot — a database that is up with degraded archiving beats a
# database that refuses to start, which is how the rest of this file treats
# archive failures.
reanchor_pgbackrest_repo_path_if_reidentified() {
[ -z "${WAL_ARCHIVE_BUCKET:-}" ] && return 0
[ ! -f "$PGDATA/global/pg_control" ] && return 0
# No marker: nothing is anchored yet, so there is nothing to re-anchor.
# derive_pgbackrest_repo_path writes both files from the live cluster.
[ ! -f "$PGBACKREST_REPO_PATH_MARKER" ] && return 0
local live_sysid live_major
live_sysid=$(read_postgres_sysid)
live_major=$(read_postgres_major)
if [ -z "$live_sysid" ] || [ -z "$live_major" ]; then
echo "pgbackrest: could not read the cluster identity (sysid='${live_sysid}', PG_VERSION='${live_major}'); leaving the archive path as-is" >&2
return 0
fi
local anchor_sysid anchor_major
anchor_sysid=$(read_pgbackrest_anchor_field sysid)
anchor_major=$(read_pgbackrest_anchor_field pg_version)
# Volume written before the anchor existed: adopt the live identity for the
# path already in the marker. Backfilling is the only safe reading of a
# missing anchor — "no fingerprint" is not evidence of a changed cluster,
# and the marker's path is where this cluster's archive already lives.
# Re-anchoring on a missing anchor would move every existing PITR-enabled
# service to a new prefix on its next redeploy.
if [ -z "$anchor_sysid" ] || [ -z "$anchor_major" ]; then
if write_pgbackrest_repo_anchor "$live_sysid" "$live_major"; then
echo "pgbackrest: adopted repo-path anchor (sysid=${live_sysid}, pg=${live_major}) for the existing archive path"
else
echo "pgbackrest: could not write the repo-path anchor; cluster re-identification stays undetectable until it succeeds" >&2
fi
return 0
fi
if [ "$anchor_sysid" = "$live_sysid" ] && [ "$anchor_major" = "$live_major" ]; then
return 0
fi
local current_path new_path user_path
current_path=$(cat "$PGBACKREST_REPO_PATH_MARKER" 2>/dev/null || true)
user_path="${WAL_ARCHIVE_PATH:-/pgbackrest}"
new_path="${user_path%/}/cluster-${live_sysid}"
echo "pgbackrest: cluster re-identified (anchored sysid=${anchor_sysid} pg=${anchor_major}, on disk sysid=${live_sysid} pg=${live_major}); re-anchoring archiving from ${current_path} to ${new_path}"
if ! reanchor_pgbackrest_repo_path "$current_path" "$new_path"; then
echo "pgbackrest: re-anchor to ${new_path} did not complete; the backup watcher retries it. Archiving stays degraded until then — the database is starting regardless." >&2
return 0
fi
if ! write_pgbackrest_repo_anchor "$live_sysid" "$live_major"; then
echo "pgbackrest: re-anchored to ${new_path} but could not write the anchor; the next boot re-runs this (idempotent)" >&2
return 0
fi
echo "pgbackrest: re-anchored to ${new_path}; stanza-create and an immediate full backup follow there. The previous cluster's archive is untouched at ${current_path}."
return 0
}
# Detect the container's effective CPU allocation. Reads cgroup v2 cpu.max
# first (Railway, modern Docker, Kubernetes ≥ 1.25), then falls back to
# cgroup v1 cpu.cfs_quota_us, then to nproc. Returns the integer ceiling
# of fractional quotas (0.5 vCPU → 1) so process-max sizing is sane on the
# smallest tier. "max"/"-1" quotas mean unlimited and use the host count.
detect_cpus() {
local quota period
if [ -r /sys/fs/cgroup/cpu.max ]; then
read -r quota period < /sys/fs/cgroup/cpu.max
if [ "$quota" != "max" ] && [ -n "$quota" ] && [ -n "$period" ] && [ "$period" -gt 0 ]; then
echo $(( (quota + period - 1) / period ))
return
fi