mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

dependabot[bot]
6 hours ago e36f9dc8e309999aa3e017e8cb83bdecf100f8ff
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
#!/usr/bin/env bash
# The contents of this file are subject to the terms of the Common Development and
# Distribution License (the License). You may not use this file except in compliance with the
# License.
#
# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
# specific language governing permission and limitations under the License.
#
# When distributing Covered Software, include this CDDL Header Notice in each file and include
# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
# Header, with the fields enclosed by brackets [] replaced by your own identifying
# information: "Portions copyright [year] [name of copyright owner]".
#
# Copyright 2026 3A Systems, LLC.
 
# Joins this server to the replication topology (#1086). run.sh starts it in the background
# next to the server on every start, not as a step of the first bootstrap: a join that could
# not complete is tried again on the next start, and membership that changed while no
# container ran is repaired. It covers OPENDJ_REPLICATION_TYPE=simple; the one-shot srs, sdsr
# and rg paths stay in replicate.sh.
#
# The contract with the operator: REPLICATION_PEERS lists every server of the topology by DNS
# name, and all of them share BASE_DN, ROOT_USER_DN, ROOT_PASSWORD, ADMIN_PORT and
# REPLICATION_PORT. A StatefulSet chart derives the list from its ordinals
# (<sts>-0.<svc> ... <sts>-N-1.<svc>), plain docker run passes the container names of
# containers whose --hostname is their --name. MASTER_SERVER keeps working as a one-element
# list. This server recognises itself in the list by a name that equals hostname -f, or is
# hostname -f cut at a dot (a StatefulSet pod whose FQDN is <sts>-N.<svc>.<ns>.svc.<domain>
# is listed as <sts>-N.<svc>), or the other way round; and, as the grep of /etc/hosts it
# replaces did, by one of its own addresses or a name /etc/hosts gives one of them. Names
# are compared whole - that grep took opendj-1 for opendj-10, a replica for its master when
# an --add-host named the master, and a master that lost its volume for a replica of itself -
# and cut only at a dot, so opendj-0.opendj.east is not opendj-0.opendj.west.
#
# Membership is decided from what is there, never from an exit code: exit 5 of dsreplication
# enable (REPLICATION_CANNOT_BE_ENABLED_ON_BASEDN) means "no suffix was left to enable",
# which covers a base DN that is already replicated and a base DN that one of the two
# servers does not hold - the race with a peer whose bootstrap has not created the backend
# yet. The step is a member once the replication domain for BASE_DN exists in its cn=config
# and the server is registered in cn=admin data; anything else is tried again, whatever the
# exit code, because a repeated enable is safe exactly when it is decided this way.
#
# Whose data the topology carries follows what each volume went through, not whether BASE_DN
# has entries: every fresh volume has entries (setup.sh imports the base entry or
# SAMPLE_DATA), and two freshly bootstrapped volumes even share a generation ID, so
# replication silently carries nothing that reached one of them by import-ldif. run.sh marks
# a volume it bootstrapped with $INITIALIZE_PENDING, and the join publishes that to its peers
# in $STATE_DN, a local entry of cn=config: "pending" while the marker is there, "ready" once
# the volume holds the data of the topology - it was never bootstrapped by run.sh, it was
# initialized from a ready peer, or it seeded the topology - and "rejoining" while a volume
# that holds it has its replication taken down to enable it anew, or while a replication
# server is not found a member yet on this start. A server joins only through a ready peer,
# and a pending one initializes only from one, so two fresh servers that start
# together never take each other's bootstrap data for the topology's, and two servers whose
# replication is down never form a topology of their own; where the list is only
# MASTER_SERVER, a peer that publishes no state (an image before this one) counts as ready, as
# the old replicate.sh trusted its master. The seed is only the first entry of
# REPLICATION_PEERS: at once when every other peer answers and is pending (all of them start
# from scratch, together or not), or once the retries are exhausted while no other peer
# answers that may hold the data - a ready one, one of an image before this one that
# publishes no state but replicates BASE_DN, or one that refuses the bind with ROOT_PASSWORD
# (only a server past its bootstrap can have another root password). The residual risk is
# documented in the README: every other server down *and* the first peer's volume lost means
# the first peer seeds empty.
#
# On a volume that waits for the data of the topology, the health marker is written only
# once the join succeeded or the seed rule fired, so a container that never joined stays
# unhealthy - across restarts too, which the bootstrap-only replicate.sh could not say (a
# restart wrote the marker right after upgrade and a failed join turned into a healthy,
# unreplicated server). A volume that holds the data is healthy as soon as it serves (see
# run.sh) - unless the join takes its replication down to enable it anew: from then until the
# enable registered it again it is not, across restarts too ($REJOIN_PENDING), and the
# backend of BASE_DN refuses the writes of clients, which no longer replicate meanwhile.
#
# REPLICATION_ROLE (#1178) is the part this server takes: combined, a directory server with a
# replication server of its own (the default); directory, one that connects to the
# replication servers of other peers; replication, a replication server that holds no data.
# The role of each side of an enable is passed to dsreplication: unless it is told otherwise,
# the tool gives both servers a replication server and a domain for BASE_DN, so an enable
# through a directory server would hand it a replication server. A peer's role is read from
# the peer - what its configuration holds, or else what it publishes next to its state. A
# replication server goes the way of a volume that waits for the data of the topology,
# publishing itself pending until it is a member, but nothing initializes it; it never seeds,
# and one listed first is passed over. A directory server joins only a topology that has a
# replication server: dsreplication enable refuses one without, before it changes anything.
# The role is set when setup.sh sets the volume up - a replication server gets no backend for
# BASE_DN - and recorded on it: a later start in another role only says so, and the join goes
# on in the role the server has (ROLE below).
 
cd /opt/opendj || exit 1
export PATH=/opt/opendj/bin:$PATH
 
BASE_DN=${BASE_DN:-"dc=example,dc=com"}
ROOT_USER_DN=${ROOT_USER_DN:-"cn=Directory Manager"}
ROOT_PASSWORD=${ROOT_PASSWORD:-password}
ADMIN_PORT=${ADMIN_PORT:-4444}
REPLICATION_PORT=${REPLICATION_PORT:-8989}
MYHOSTNAME=${MYHOSTNAME:-$(hostname -f)}
BOOTSTRAP_COMPLETE=${BOOTSTRAP_COMPLETE:-/opt/opendj/.bootstrap-complete}
INITIALIZE_PENDING=${INITIALIZE_PENDING:-/opt/opendj/data/.replication-initialize-pending}
REJOIN_PENDING=${REJOIN_PENDING:-/opt/opendj/data/.replication-rejoin-pending}
# the role the volume was set up with (setup.sh), or the one a first start of this image read
# from a volume set up before roles
REPLICATION_ROLE_FILE=${REPLICATION_ROLE_FILE:-/opt/opendj/data/.replication-role}
# not replicated: cn=config is this server's alone, and it lives on the volume next to the
# marker, so the two cannot tell different stories
STATE_DN="cn=Docker Join,cn=config"
# BASE_DN as a value in a search filter (RFC 4515): a parenthesis, an asterisk or a backslash
# in it would break the filter, or make it match more than BASE_DN
BASE_DN_VALUE=$(printf '%s' "$BASE_DN" | sed -e 's/\\/\\5c/g' -e 's/\*/\\2a/g' -e 's/(/\\28/g' -e 's/)/\\29/g')
 
# Sets the variable to its value read as a decimal whole number, or to the default when it
# is not one or is below the least value. $(( )) reads a leading zero as octal and fails on
# 08 - abandoning the whole command it runs in, however far up that is - while test reads 08
# as 8, so a check by test alone lets the value through to the arithmetic.
whole_number() { # <variable> <default> <least>
  local value=${!1}
  case $value in
    '' | *[!0-9]*) value= ;;
    *) value=$((10#$value)) ;;
  esac
  if [ -z "$value" ] || [ "$value" -lt "$3" ]; then
    echo "join: $1 is not a whole number of at least $3, using $2"
    value=$2
  fi
  printf -v "$1" '%s' "$value"
}
 
REPLICATION_RETRY_COUNT=${REPLICATION_RETRY_COUNT:-30}
whole_number REPLICATION_RETRY_COUNT 30 1
REPLICATION_RETRY_INTERVAL=${REPLICATION_RETRY_INTERVAL:-10}
whole_number REPLICATION_RETRY_INTERVAL 10 0
# dsreplication enable can hang rather than fail (a peer that stops mid-operation), so no
# enable runs without a bound
REPLICATION_ATTEMPT_TIMEOUT=${REPLICATION_ATTEMPT_TIMEOUT:-120}
whole_number REPLICATION_ATTEMPT_TIMEOUT 120 1
# a total update is a full import of BASE_DN, which takes as long as the data needs; a killed
# dsreplication initialize leaves its task running on the server, and the next attempt asks
# for another full import, so by default it runs without a bound
REPLICATION_INITIALIZE_TIMEOUT=${REPLICATION_INITIALIZE_TIMEOUT:-0}
whole_number REPLICATION_INITIALIZE_TIMEOUT 0 0
 
# a role it does not know is not taken for the default: a server started as a replication
# server by a misspelt value would otherwise join holding data. A volume that records its
# role (see ROLE below) joins in that role whatever the value, as it does with another role
REPLICATION_ROLE=${REPLICATION_ROLE:-combined}
case $REPLICATION_ROLE in
  combined | directory | replication) ;;
  *)
    if [ -s "$REPLICATION_ROLE_FILE" ]; then
      echo "join: REPLICATION_ROLE=$REPLICATION_ROLE is not one of combined, directory or replication, the role recorded on the volume stands"
    else
      echo "join: REPLICATION_ROLE=$REPLICATION_ROLE is not one of combined, directory or replication, this server does not join"
      exit 1
    fi
    ;;
esac
 
# Removing servers that left the topology needs the operator's word on who belongs to it:
# with only MASTER_SERVER set, a third server joined by hand would be "not in the list" and
# thrown out. So the cleanup, and the repair of the replication server lists, run only when
# REPLICATION_PEERS itself is set.
PEERS_ARE_EXPLICIT=${REPLICATION_PEERS:+yes}
REPLICATION_PEERS=${REPLICATION_PEERS:-$MASTER_SERVER}
 
if [ -z "$REPLICATION_PEERS" ]; then
  echo "join: neither REPLICATION_PEERS nor MASTER_SERVER is set, nothing to join"
  exit 1
fi
 
IFS=',' read -r -a RAW_PEERS <<<"$REPLICATION_PEERS"
PEERS=()
for peer in "${RAW_PEERS[@]}"; do
  peer=$(echo "$peer" | tr -d '[:space:]')
  [ -n "$peer" ] && PEERS+=("$peer")
done
if [ "${#PEERS[@]}" -eq 0 ]; then
  echo "join: REPLICATION_PEERS holds no peer"
  exit 1
fi
 
# The tools read the root password from a file (#1084); the file lives on the tmpfs of
# /dev/shm where there is one, and run.sh removes what a killed join left behind, by the
# name, on every start - in /dev/shm those of its ADMIN_PORT, in /tmp all of them
PASSWORD_FILE=$(mktemp -p /dev/shm "opendj-join.$ADMIN_PORT.XXXXXX" 2>/dev/null \
  || mktemp "/tmp/opendj-join.$ADMIN_PORT.XXXXXX") || exit 1
trap 'rm -f "$PASSWORD_FILE"' EXIT
printf '%s\n' "$ROOT_PASSWORD" >"$PASSWORD_FILE" || exit 1
 
lower() {
  printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
 
# an IPv4 address is digits and dots, an IPv6 one has a colon
is_address() {
  case $1 in
    *:*) return 0 ;;
    *[!0-9.]* | '') return 1 ;;
    *) return 0 ;;
  esac
}
 
# Two names (lower case) are one server when they are equal, or when one of them is the
# other cut at a dot; addresses only when they are equal
names_match() {
  [ "$1" = "$2" ] && return 0
  if is_address "$1" || is_address "$2"; then
    return 1
  fi
  case $1 in "$2".*) return 0 ;; esac
  case $2 in "$1".*) return 0 ;; esac
  return 1
}
 
SELF_NAME=$(lower "$MYHOSTNAME")
# the addresses of this container (loopback aside) and the names /etc/hosts gives them - an
# --add-host or a hostAliases entry for itself - as the base replicate.sh recognised them
SELF_ADDRESSES=()
SELF_ALIASES=()
own_names=" $(lower "$(hostname)") $(lower "$(hostname -f 2>/dev/null)") $SELF_NAME "
for address in $(hostname -i 2>/dev/null) \
    $(awk -v names="$own_names" '!/^[[:space:]]*#/ { for (i = 2; i <= NF; i++) if (index(names, " " tolower($i) " ")) { print $1; break } }' /etc/hosts 2>/dev/null); do
  case $address in 127.* | ::1 | 0.0.0.0) continue ;; esac
  SELF_ADDRESSES+=("$(lower "$address")")
done
if [ "${#SELF_ADDRESSES[@]}" -gt 0 ]; then
  for name in $(awk -v addresses=" ${SELF_ADDRESSES[*]} " '!/^[[:space:]]*#/ && index(addresses, " " tolower($1) " ") { for (i = 2; i <= NF; i++) print tolower($i) }' /etc/hosts 2>/dev/null); do
    SELF_ALIASES+=("$name")
  done
fi
 
is_self() {
  local peer candidate
  peer=$(lower "$1")
  names_match "$peer" "$SELF_NAME" && return 0
  for candidate in "${SELF_ADDRESSES[@]}" "${SELF_ALIASES[@]}"; do
    [ "$peer" = "$candidate" ] && return 0
  done
  return 1
}
 
in_peers() {
  local host peer
  host=$(lower "$1")
  for peer in "${PEERS[@]}"; do
    names_match "$(lower "$peer")" "$host" && return 0
  done
  return 1
}
 
# every LDAP operation goes through the administration connector, which always serves TLS;
# the root user may change its password after the bootstrap, so unlike the health check
# these tools run only while the join still has the bootstrap's ROOT_PASSWORD to work with
search() {
  local host=$1
  shift
  ldapsearch --noPropertiesFile --hostname "$host" --port "$ADMIN_PORT" --useSsl --trustAll \
    --bindDN "$ROOT_USER_DN" --bindPasswordFile "$PASSWORD_FILE" "$@" 2>/dev/null
}
 
ldapmodify_on() { # <host> [<option>...]
  local host=$1
  shift
  ldapmodify --noPropertiesFile --hostname "$host" --port "$ADMIN_PORT" --useSsl --trustAll \
    --bindDN "$ROOT_USER_DN" --bindPasswordFile "$PASSWORD_FILE" "$@" 2>/dev/null
}
 
ldapmodify_local() {
  ldapmodify_on localhost "$@"
}
 
dsconfig_local() {
  dsconfig "$@" --hostname localhost --port "$ADMIN_PORT" --bindDN "$ROOT_USER_DN" \
    --bindPasswordFile "$PASSWORD_FILE" --trustAll --no-prompt
}
 
# runs a command within that many seconds, or without a bound for 0; timeout executes the
# command, so it has to be a program, not a function of this script
bounded() {
  local limit=$1
  shift
  if [ "$limit" -gt 0 ] 2>/dev/null; then
    timeout "$limit" "$@"
  else
    "$@"
  fi
}
 
server_up() {
  search localhost --baseDN "" --searchScope base "(objectClass=*)" 1.1 >/dev/null
}
 
# the state goes with the role of this server, after it: a peer reads the first word as the
# state, which a join of an image before roles reads alone
publish_state() {
  printf 'dn: %s\nchangetype: modify\nreplace: description\ndescription: %s %s\n' "$STATE_DN" "$1" "$ROLE" \
    | ldapmodify_local >/dev/null && return 0
  printf 'dn: %s\nobjectClass: top\nobjectClass: ds-cfg-branch\nobjectClass: extensibleObject\ncn: Docker Join\ndescription: %s %s\n' "$STATE_DN" "$1" "$ROLE" \
    | ldapmodify_local --defaultAdd >/dev/null && return 0
  echo "join: could not publish the state $1 of this server to its peers"
  return 1
}
 
# ready, pending or rejoining as the peer publishes it, absent when it answers without
# publishing any, refused when it answers but no longer takes ROOT_PASSWORD, down when it does
# not answer
peer_state() {
  local out state
  out=$(search "$1" --baseDN "$STATE_DN" --searchScope base "(objectClass=*)" description)
  case $? in
    0)
      state=$(printf '%s\n' "$out" | awk 'tolower($1) == "description:" { print $2; exit }')
      case $state in
        ready | pending | rejoining) echo "$state" ;;
        *) echo absent ;;
      esac
      ;;
    32) echo absent ;;
    # 49: invalidCredentials - the root password of a server is changed in its cn=config,
    # which is not replicated, and only a server past its bootstrap has another one
    49) echo refused ;;
    *) echo down ;;
  esac
}
 
trusted() {
  case $1 in
    ready) return 0 ;;
    absent) [ "$PEERS_ARE_EXPLICIT" != yes ] ;;
    *) return 1 ;;
  esac
}
 
# fails when the search does, rather than answering with an empty list
registered_hosts() { # [<host>, localhost by default]
  local out
  out=$(search "${1:-localhost}" --baseDN "cn=Servers,cn=admin data" --searchScope one "(objectClass=*)" hostname) \
    || return 1
  printf '%s\n' "$out" | awk 'tolower($1) == "hostname:" { print $2 }'
}
 
# a member holds the replication domain for BASE_DN in its configuration - a replication
# server its replication server instead (see holds_own_part) - and is registered
# in cn=admin data - under the name the enable that registered it connected with, which for
# a server that never ran an enable of its own is the name a peer listed it by; both are made
# by one dsreplication enable, so requiring both keeps a half-done enable from being declared
# a success, and the next round takes it down (see reset_if_unregistered)
replicates_base_dn() { # <host>
  grep -qx domain <<<"$(replication_config "$1")"
}
 
# What of the replication the server holds, one line each: "server" for a replication server,
# "domain" for the replication domain of BASE_DN, "backend" for a backend of BASE_DN, and
# "published <role>" for the state it publishes - the role is left out where the state comes
# without one, from an image before roles - all from one search, as every ldapsearch starts a
# JVM. Nothing for a server that has none of
# them, and a failure when it cannot be asked.
replication_config() { # <host>
  local out
  out=$(search "$1" --baseDN "cn=config" --searchScope sub \
    "(|(objectClass=ds-cfg-replication-server)(&(objectClass=ds-cfg-replication-domain)(ds-cfg-base-dn=$BASE_DN_VALUE))(&(objectClass=ds-cfg-backend)(ds-cfg-base-dn=$BASE_DN_VALUE))(cn=Docker Join))" \
    objectClass description) \
    || return 1
  printf '%s\n' "$out" | awk -v state="$(lower "$STATE_DN")" '
    /^dn: / { is_state = (tolower(substr($0, 5)) == state) }
    tolower($0) == "objectclass: ds-cfg-replication-server" { print "server" }
    tolower($0) == "objectclass: ds-cfg-replication-domain" { print "domain" }
    tolower($0) == "objectclass: ds-cfg-backend" { print "backend" }
    is_state && tolower($1) == "description:" { print "published", $3 }'
}
 
# What a replication configuration holds: "combined" for a replication server and the domain
# of BASE_DN, "directory" for the domain alone, "server" for a replication server alone,
# nothing for neither. Only the first two tell a role by themselves.
config_shape() { # <replication_config output>
  local server=no domain=no
  grep -qx server <<<"$1" && server=yes
  grep -qx domain <<<"$1" && domain=yes
  case $server$domain in
    yesyes) echo combined ;;
    noyes) echo directory ;;
    yesno) echo server ;;
  esac
}
 
# The role of a peer: the one its configuration tells, or else the one it publishes next to
# its state, which tells a replication server from a combined server whose enable stopped
# half-way, before it configured the domain - and combined for a peer that publishes none, an
# image before roles or a master that runs no join, as every server was before roles.
peer_role() { # <replication_config output>
  case $(config_shape "$1") in
    combined) echo combined ;;
    directory) echo directory ;;
    *) published_role "$1" ;;
  esac
}
 
# the role a server publishes next to its state; combined for one that publishes none, as a
# server of an image before roles is one - or a server that runs no join, whose state entry a
# search does not find
published_role() { # <replication_config output>
  local role
  role=$(awk '$1 == "published" { print $2; exit }' <<<"$1")
  case $role in
    directory | replication) echo "$role" ;;
    *) echo combined ;;
  esac
}
 
# this server holds what its role gives it: the replication domain of BASE_DN for a combined
# or a directory server, a replication server for a replication server
holds_own_part() {
  local config
  config=$(replication_config localhost) || return 1
  if [ "$ROLE" = replication ]; then
    grep -qx server <<<"$config"
  else
    grep -qx domain <<<"$config"
  fi
}
 
# the dsreplication enable arguments that give the server of that side its role, one a line
role_arguments() { # <1|2> <role>
  case $2 in
    directory) printf '%s\n' "--noReplicationServer$1" ;;
    replication) printf '%s\n' "--onlyReplicationServer$1" "--replicationPort$1" "$REPLICATION_PORT" ;;
    *) printf '%s\n' "--replicationPort$1" "$REPLICATION_PORT" ;;
  esac
}
 
is_member() {
  local host
  holds_own_part || return 1
  for host in $(registered_hosts); do
    is_self "$host" && return 0
  done
  return 1
}
 
# cn=admin data is replicated, but a server that was away while the survivors removed it
# from the topology still holds its own entry until that delete reaches it, and it may not
# have yet when the join looks: the other peers that answer and replicate BASE_DN have the
# say - a replication server as well, which replicates cn=admin data without BASE_DN. Being
# unregistered takes this server's replication down (reset_replication), so it is
# concluded only when at least one of them listed its registrations and none of them lists
# this server: a search that failed, or a peer whose copy of cn=admin data still lags behind
# the others, decides nothing while another peer lists it. With no such peer the local view
# stands - nothing else can be asked.
registered_with_peers() {
  local peer host hosts config asked=no
  for peer in "${PEERS[@]}"; do
    is_self "$peer" && continue
    # a replication server alone is a member only as a replication server; on a combined
    # server it is an enable that stopped before the domain and the initialize of cn=admin
    # data, whose registrations are its own
    config=$(replication_config "$peer") || continue
    case $(config_shape "$config") in
      combined | directory) ;;
      server) [ "$(peer_role "$config")" = replication ] || continue ;;
      *) continue ;;
    esac
    hosts=$(registered_hosts "$peer") || continue
    asked=yes
    for host in $hosts; do
      is_self "$host" && return 0
    done
  done
  [ "$asked" = no ]
}
 
member_of_topology() {
  is_member && registered_with_peers
}
 
# whether the cn=admin data of this server registers it: 0 when it does, 1 when a search
# answers that it does not - cn=Servers missing altogether included, which an initialize of
# cn=admin data that failed half-way leaves - and 2 when the search failed otherwise, which
# decides nothing
registered_here() {
  local out host
  out=$(search localhost --baseDN "cn=Servers,cn=admin data" --searchScope one "(objectClass=*)" hostname)
  case $? in
    0) ;;
    32) return 1 ;;
    *) return 2 ;;
  esac
  for host in $(printf '%s\n' "$out" | awk 'tolower($1) == "hostname:" { print $2 }'); do
    is_self "$host" && return 0
  done
  return 1
}
 
# Two dsreplication enable runs through one peer at the same time break each other: both
# create the replication server that a seed does not have yet, the loser fails half-way
# (ManagedObjectAlreadyExistsException, exit 17), and every enable after that exits 5,
# "already replicated", without ever completing its membership. So an enable holds a lock on
# the peer it runs through - an entry of the peer's cn=config, which only one add creates -
# and one on this server: an enable configures both of its servers, and a server that enables
# through a peer may at the same time be the peer another server enables through. A server
# that takes its own replication down holds its own lock for as long.
# A lock whose holder was killed before it removed it is broken once it is older than an
# enable may take, by a delete that asserts the value it read: two joins that break it at
# once cannot remove the lock one of them took right after. One that holds this server's own
# name is broken at once: it was left by a join of an earlier start, and a start runs one.
JOIN_LOCK_DN="cn=Docker Join Lock,cn=config"
JOIN_LOCK_TTL=$((REPLICATION_ATTEMPT_TIMEOUT + 60))
JOIN_LOCK_VALUE=
 
add_join_lock() { # <host> <value>
  printf 'dn: %s\nobjectClass: top\nobjectClass: ds-cfg-branch\nobjectClass: extensibleObject\ncn: Docker Join Lock\ndescription: %s\n' "$JOIN_LOCK_DN" "$2" \
    | ldapmodify_on "$1" --defaultAdd >/dev/null
}
 
# Succeeds when this server may go on: it took the lock and left its value in
# JOIN_LOCK_VALUE, or the server could not be asked for one - a peer that does not answer
# fails the enable anyway, and one that refuses the entry for another reason than holding it
# must not keep the join out for good. A lock taken this way is released with
# release_join_lock <host> "$JOIN_LOCK_VALUE".
take_join_lock() { # <host>
  local value rc held holder since age where
  JOIN_LOCK_VALUE=
  value="$SELF_NAME $(date +%s)"
  add_join_lock "$1" "$value"
  rc=$?
  if [ "$rc" -eq 0 ]; then
    JOIN_LOCK_VALUE=$value
    return 0
  fi
  # 68: entryAlreadyExists
  [ "$rc" -eq 68 ] || return 0
  held=$(search "$1" --baseDN "$JOIN_LOCK_DN" --searchScope base "(objectClass=*)" description \
    | awk 'tolower($1) == "description:" { print $2, $3; exit }')
  holder=${held% *}
  since=${held##* }
  age=$(($(date +%s) - ${since:-0}))
  if [ -n "$held" ] && { [ "$holder" = "$SELF_NAME" ] || [ "$age" -gt "$JOIN_LOCK_TTL" ]; }; then
    echo "join: breaking the lock $holder took on $1 $age s ago"
    printf 'dn: %s\nchangetype: delete\n' "$JOIN_LOCK_DN" \
      | ldapmodify_on "$1" --assertionFilter "(description=$held)" >/dev/null
    if add_join_lock "$1" "$value"; then
      JOIN_LOCK_VALUE=$value
      return 0
    fi
  fi
  if [ "$1" = localhost ]; then where="this server"; else where=$1; fi
  # a lock released between the add and the search has no holder left to name
  echo "join: ${holder:-another server} is enabling replication through $where, waiting for it"
  return 1
}
 
release_join_lock() { # <host> <value>
  [ -n "$2" ] || return 0
  printf 'dn: %s\nchangetype: delete\n' "$JOIN_LOCK_DN" \
    | ldapmodify_on "$1" --assertionFilter "(description=$2)" >/dev/null \
    || echo "join: could not remove the lock on $1, it expires in $JOIN_LOCK_TTL s"
}
 
# exits 75 without running the enable while another server enables through the peer or
# through this one. Neither lock is waited for, so two servers that each hold their own and
# ask for the other's cannot deadlock; they both give up and try again after pause().
# It exits 75 as well when the peer's role cannot be read - guessed wrong, the enable would
# give a directory server a replication server - and when a directory server would enable
# with a directory server that no replication server joined yet: that topology has none, and
# dsreplication enable refuses it. The peer is read under its lock, which an enable through
# it by another server holds while it changes what the peer runs.
enable_through() {
  local rc own peer_lock config role peer_args own_args
  take_join_lock localhost || return 75
  own=$JOIN_LOCK_VALUE
  if ! take_join_lock "$1"; then
    release_join_lock localhost "$own"
    return 75
  fi
  peer_lock=$JOIN_LOCK_VALUE
  if ! config=$(replication_config "$1"); then
    echo "join: could not read how $1 replicates"
    rc=75
  elif role=$(peer_role "$config") && [ "$ROLE" = directory ] && [ "$role" = directory ] \
    && [ -z "$(config_shape "$config")" ]; then
    echo "join: $1 is a directory server that no replication server joined yet, and a directory server joins only through a topology that has one"
    rc=75
  else
    mapfile -t peer_args < <(role_arguments 1 "$role")
    mapfile -t own_args < <(role_arguments 2 "$ROLE")
    bounded "$REPLICATION_ATTEMPT_TIMEOUT" dsreplication enable \
      --host1 "$1" --port1 "$ADMIN_PORT" --bindDN1 "$ROOT_USER_DN" \
      --bindPasswordFile1 "$PASSWORD_FILE" "${peer_args[@]}" \
      --host2 "$MYHOSTNAME" --port2 "$ADMIN_PORT" --bindDN2 "$ROOT_USER_DN" \
      --bindPasswordFile2 "$PASSWORD_FILE" "${own_args[@]}" \
      --adminUID admin --adminPasswordFile "$PASSWORD_FILE" \
      --baseDN "$BASE_DN" -X -n
    rc=$?
  fi
  release_join_lock "$1" "$peer_lock"
  release_join_lock localhost "$own"
  return $rc
}
 
# the retry interval and up to as much again, at random: two joins that keep missing each
# other's lock would otherwise try again in step, round after round
pause() {
  local delay=$((REPLICATION_RETRY_INTERVAL + RANDOM % (REPLICATION_RETRY_INTERVAL + 1)))
  echo "$1, trying again in $delay s"
  sleep "$delay"
}
 
initialize_from() {
  bounded "$REPLICATION_INITIALIZE_TIMEOUT" dsreplication initialize --baseDN "$BASE_DN" \
    --adminUID admin --adminPasswordFile "$PASSWORD_FILE" \
    --hostSource "$1" --portSource "$ADMIN_PORT" \
    --hostDestination "$MYHOSTNAME" --portDestination "$ADMIN_PORT" -X -n
}
 
# the generation ID of the replication domain, from the domain's own monitor entry: a
# replication server also puts domain-name, connected-to and generation-id on the entry of
# every directory server connected to it, but only the domain publishes replayed-updates
generation_id() {
  search "$1" --baseDN "cn=monitor" --searchScope sub \
    "(&(domain-name=$BASE_DN_VALUE)(replayed-updates=*))" generation-id \
    | awk 'tolower($1) == "generation-id:" { print $2; exit }'
}
 
cross_check() { # <peer> <what the mismatch means>
  local local_id peer_id
  local_id=$(generation_id localhost)
  peer_id=$(generation_id "$1")
  if [ -n "$local_id" ] && [ -n "$peer_id" ] && [ "$local_id" != "$peer_id" ]; then
    echo "join: generation ID $local_id does not match $peer_id of $1$2"
  fi
}
 
# the first other peer, in the order of the list, that holds the data of the topology and
# replicates BASE_DN - asked of the peer itself, as cn=admin data registers a server under
# whatever name the enable that registered it used, which need not be the listed one
trusted_source() {
  local peer
  for peer in "${PEERS[@]}"; do
    is_self "$peer" && continue
    trusted "$(peer_state "$peer")" && replicates_base_dn "$peer" && { echo "$peer"; return 0; }
  done
  return 1
}
 
# every other peer answers and waits for the data of the topology itself; true as well when
# the list names no other server
all_others_pending() {
  local peer
  for peer in "${PEERS[@]}"; do
    is_self "$peer" && continue
    [ "$(peer_state "$peer")" = pending ] || return 1
  done
  return 0
}
 
# some other peer answers and may hold the data of the topology: it is ready, it is rejoining
# (it holds the data while its replication is down), it refuses ROOT_PASSWORD, or it publishes
# no state and replicates BASE_DN. Unlike an initialize source,
# the last counts here even where the peers are explicit - a server of an image before this
# one, still serving the topology, and seeding next to it would fork it. A server of this
# image publishes no state only while it bootstraps, and it replicates nothing then, so it
# does not hold the seed off. A refusing peer cannot be asked what it replicates, but only a
# server past its bootstrap has a root password other than ROOT_PASSWORD.
any_other_may_hold_data() {
  local peer
  for peer in "${PEERS[@]}"; do
    is_self "$peer" && continue
    case $(peer_state "$peer") in
      ready | rejoining | refused) return 0 ;;
      absent) replicates_base_dn "$peer" && return 0 ;;
    esac
  done
  return 1
}
 
# The values of the replication server lists this server holds: one "<kind>\t<cn>" line for
# every list, its replication server entry (kind server) and each of its replication domains
# (kind domain) - BASE_DN, and cn=schema and cn=admin data, which dsreplication enable
# configures next to it - followed by a "<kind>\t<cn>\t<host:port>" line for every value
replication_server_lists() {
  search localhost --baseDN "cn=config" --searchScope sub \
    "(|(objectClass=ds-cfg-replication-server)(objectClass=ds-cfg-replication-domain))" \
    objectClass cn ds-cfg-replication-server \
    | awk -v OFS='\t' '
        function flush(   i) {
          if (kind != "") { print kind, cn; for (i = 1; i <= n; i++) print kind, cn, values[i] }
          kind = ""; cn = ""; n = 0
        }
        /^dn: / { flush() }
        tolower($0) == "objectclass: ds-cfg-replication-domain" { kind = "domain" }
        tolower($0) == "objectclass: ds-cfg-replication-server" { kind = "server" }
        tolower($1) == "cn:" { cn = substr($0, 5) }
        tolower($1) == "ds-cfg-replication-server:" { values[++n] = $2 }
        END { flush() }'
}
 
# it runs inside loops that read their lines from stdin, which dsconfig is kept away from
change_replication_servers() { # <kind> <cn> --add|--remove <host:port>
  if [ "$1" = server ]; then
    dsconfig_local set-replication-server-prop --provider-name "Multimaster Synchronization" \
      "$3" "replication-server:$4" </dev/null
  else
    dsconfig_local set-replication-domain-prop --provider-name "Multimaster Synchronization" \
      --domain-name "$2" "$3" "replication-server:$4" </dev/null
  fi
}
 
# "<dn>\t<hostname>" for every server this server finds in its cn=admin data
registrations() {
  search localhost --baseDN "cn=Servers,cn=admin data" --searchScope one "(objectClass=*)" hostname \
    | awk '/^dn: /{dn=substr($0,5)} tolower($1) == "hostname:" {print dn "\t" $2}'
}
 
# removes a server's entry and its group membership from cn=admin data
unregister() { # <dn>
  printf 'dn: %s\nchangetype: delete\n' "$1" | ldapmodify_local >/dev/null || return 1
  printf 'dn: cn=all-servers,cn=Server Groups,cn=admin data\nchangetype: modify\ndelete: uniqueMember\nuniqueMember: %s\n' "${1%%,cn=Servers,cn=admin data}" \
    | ldapmodify_local >/dev/null || true
}
 
# A server that the survivors removed from the topology while it was away, and that comes
# back on the volume it kept, still replicates BASE_DN and cn=admin data. dsreplication enable
# registers a server only where the administration data of one of the two is not replicated
# yet: with both replicated and their registries alike - this server's copy lacks it once the
# delete of the survivors reached it - the enable updates the replication server lists and
# leaves cn=admin data as it is, and this server stays unregistered for good. So its own
# replication configuration is taken down first, and the enable after that registers it
# anew. A copy of its entry that the delete has not reached yet is dropped as well: the
# registries would differ, the enable would merge them, and the delete arriving later would
# take this server out again.
# From the first change on, writes this server takes get no change number and reach no other
# server. Removing the health marker is not enough to stop them: new probes fail, but a
# container turns unhealthy only after its probe's retries (a readiness probe after its
# failure threshold), by when the enable has usually run, and connections that are open stay
# open. So before anything changes the backend of BASE_DN refuses the writes of clients (see
# hold_writes), and joined() or seed() lets them in again. $REJOIN_PENDING keeps run.sh from
# reporting a restart healthy until then. A volume that holds the data of the topology
# publishes the state rejoining meanwhile, which keeps the peers from enabling or
# initializing through it while it has no replication of its own, and keeps a first peer
# from seeding next to it; one that waits for that data stays pending. It holds its own join
# lock while its configuration goes, so that no server enables through it then. One attempt:
# a lock another join holds leaves everything as it was, and the next round tries again.
reset_replication() { # <rejoining|pending> <why>
  local dn host own
  echo "join: $2, taking its replication configuration down to enable it anew"
  publish_state "$1" || return 1
  touch "$REJOIN_PENDING"
  rm -f "$BOOTSTRAP_COMPLETE"
  if ! take_join_lock localhost; then
    echo "join: its replication configuration stays as it is until the next round"
    return 1
  fi
  own=$JOIN_LOCK_VALUE
  # a replication server has no backend of BASE_DN, and no client writes to hold
  if [ "$ROLE" != replication ] && ! hold_writes; then
    echo "join: could not stop the writes of clients, its replication configuration stays as it is"
    release_join_lock localhost "$own"
    return 1
  fi
  registrations | while IFS=$'\t' read -r dn host; do
    [ -n "$host" ] && is_self "$host" || continue
    echo "join: dropping the stale entry $dn"
    unregister "$dn" || echo "join: could not remove $dn"
  done
  bounded "$REPLICATION_ATTEMPT_TIMEOUT" dsreplication disable --hostname "$MYHOSTNAME" --port "$ADMIN_PORT" \
    --adminUID admin --adminPasswordFile "$PASSWORD_FILE" --disableAll -X -n \
    || echo "join: dsreplication disable exited with $?"
  release_join_lock localhost "$own"
}
 
# "<backend-id> <writability-mode>" of the backend that holds BASE_DN; the mode is left out
# where the entry does not set one, which means enabled. Nothing where no backend holds it,
# and a failure when the search failed
data_backend() {
  local out
  out=$(search localhost --baseDN "cn=Backends,cn=config" --searchScope one \
    "(&(objectClass=ds-cfg-backend)(ds-cfg-base-dn=$BASE_DN_VALUE))" ds-cfg-backend-id ds-cfg-writability-mode) \
    || return 1
  printf '%s\n' "$out" | awk 'tolower($1) == "ds-cfg-backend-id:" { id = $2 } tolower($1) == "ds-cfg-writability-mode:" { mode = $2 }
           END { if (id != "") print id, mode }'
}
 
# The backend of BASE_DN takes internal and replication writes only - dsreplication enable
# and initialize go through those, a client's write is refused (Unwilling to Perform). Only
# that backend: internal-only on the whole server would refuse the writes dsreplication
# enable makes to cn=config and cn=admin data. The mode is configuration, so it outlasts a
# restart; $REJOIN_PENDING names the backend, so that release_writes lets the writes in
# again on the start that rejoins, and only where this join stopped them: a backend that an
# operator made read-only already stays as it is.
hold_writes() {
  local backend mode
  read -r backend mode <<<"$(data_backend)"
  if [ -z "$backend" ]; then
    echo "join: found no backend that holds $BASE_DN"
    return 1
  fi
  [ "${mode:-enabled}" = enabled ] || return 0
  echo "join: backend $backend refuses the writes of clients until this server rejoins"
  # named before the mode changes: a container killed in between lets the writes in again
  # once it rejoined, rather than leaving them refused for good
  printf '%s\n' "$backend" >"$REJOIN_PENDING" || return 1
  dsconfig_local set-backend-prop --backend-name "$backend" --set writability-mode:internal-only </dev/null >/dev/null
}
 
release_writes() {
  local backend
  backend=$(cat "$REJOIN_PENDING" 2>/dev/null)
  [ -n "$backend" ] || return 0
  dsconfig_local set-backend-prop --backend-name "$backend" --set writability-mode:enabled </dev/null >/dev/null || return 1
  echo "join: backend $backend takes the writes of clients again"
}
 
# Removes every server that is registered in the topology but no longer listed in
# REPLICATION_PEERS: its entry and group membership in cn=admin data (which is replicated,
# so one survivor's delete reaches the others) and its values in every replication server
# list of this server (which are configuration of this server alone, so every survivor
# prunes its own on its next start). dsreplication disable cannot do this for a dead server:
# it changes only the servers it can reach, and OpenDJ 4 has no cleanup subcommand. Every
# step tolerates losing the race to another survivor. A server registered by an address - a
# master that MASTER_SERVER named by one, whose replicas enabled through that address - is
# left alone: nothing tells it from a server listed by name, and the list moving from
# MASTER_SERVER to REPLICATION_PEERS would otherwise throw the live master out.
cleanup_departed() {
  local dn host kind cn value
  [ "$PEERS_ARE_EXPLICIT" = yes ] || return 0
  registrations | while IFS=$'\t' read -r dn host; do
    [ -z "$host" ] && continue
    is_address "$host" && continue
    if ! in_peers "$host" && ! is_self "$host"; then
      echo "join: removing departed server $host from cn=admin data"
      unregister "$dn" || echo "join: could not remove $dn (already removed by another survivor?)"
    fi
  done
  replication_server_lists | while IFS=$'\t' read -r kind cn value; do
    [ -z "$value" ] && continue
    host=${value%:*}
    is_address "$host" && continue
    if ! in_peers "$host" && ! is_self "$host"; then
      echo "join: removing departed replication server $value from the $kind list of $cn"
      change_replication_servers "$kind" "$cn" --remove "$value" || true
    fi
  done
}
 
# Adds every listed peer that is registered in the topology to each replication server list
# of this server that lacks it, under the name it is registered by - the name dsreplication
# enable writes as well, so no server is listed twice. Joins that run at the same time each
# configure the topology as they read it, and two of them can leave a pair of replication
# servers that know nothing of each other, cutting the updates of one off from the other; a
# replication server connects to a server added to its list at once. A peer that is not
# registered yet is waited for, as long as the retries last, after this server already
# reports itself healthy. Only a peer that runs a replication server goes into a list - a
# directory server has none to connect to - and cn=admin data does not say which do, so a
# peer that a list lacks is asked; one that does not answer is left for the repair of a later
# start.
repair_replication_servers() {
  local i peer waiting hosts host lists kind cn value found config servers=
  [ "$PEERS_ARE_EXPLICIT" = yes ] || return 0
  for i in $(seq 1 "$REPLICATION_RETRY_COUNT"); do
    waiting=
    # one search a round: every ldapsearch starts a JVM
    hosts=$(registered_hosts)
    for peer in "${PEERS[@]}"; do
      is_self "$peer" && continue
      found=no
      for host in $hosts; do
        names_match "$(lower "$peer")" "$(lower "$host")" && { found=yes; break; }
      done
      [ "$found" = yes ] || waiting="$waiting $peer"
    done
    [ -z "$waiting" ] && break
    [ "$i" -eq "$REPLICATION_RETRY_COUNT" ] && break
    echo "join: waiting for$waiting to register in the topology before the replication server lists are checked"
    sleep "$REPLICATION_RETRY_INTERVAL"
  done
  hosts=$(registered_hosts)
  lists=$(replication_server_lists)
  # a here-string of nothing still reads as one empty line
  [ -n "$lists" ] || return 0
  # every ldapsearch starts a JVM: a peer that every list holds already is not asked. One that
  # does not answer waits for the repair of a later start, whatever the roles around it: a
  # replication server left out of a list is added then - and its own repair adds this server
  # to its lists when it starts, which connects the two already - while a directory server put
  # into one would stay there for good, a replication server that nothing listens on, which
  # cleanup_departed keeps as long as the host is listed; and a peer that every list holds
  # is never asked again, so nothing would take it out.
  for host in $hosts; do
    is_self "$host" && continue
    in_peers "$host" || continue
    lacked_by_a_list "$host" "$lists" || continue
    if ! config=$(replication_config "$host"); then
      echo "join: could not ask $host whether it runs a replication server, its place in the replication server lists is checked on a later start"
    elif grep -qx server <<<"$config"; then
      servers="$servers $host"
    fi
  done
  while IFS=$'\t' read -r kind cn value; do
    [ -n "$value" ] && continue
    for host in $servers; do
      if ! list_holds "$host" "$kind" "$cn" "$lists"; then
        echo "join: adding replication server $host:$REPLICATION_PORT to the $kind list of $cn"
        change_replication_servers "$kind" "$cn" --add "$host:$REPLICATION_PORT" || true
      fi
    done
  done <<<"$lists"
}
 
# whether the list of that kind and cn holds the host, in replication_server_lists output
list_holds() { # <host> <kind> <cn> <lists>
  local k c v
  while IFS=$'\t' read -r k c v; do
    [ "$k" = "$2" ] && [ "$c" = "$3" ] && [ -n "$v" ] || continue
    names_match "$(lower "${v%:*}")" "$(lower "$1")" && return 0
  done <<<"$4"
  return 1
}
 
lacked_by_a_list() { # <host> <lists>
  local kind cn value
  while IFS=$'\t' read -r kind cn value; do
    [ -n "$value" ] && continue
    list_holds "$1" "$kind" "$cn" "$2" || return 0
  done <<<"$2"
  return 1
}
 
# the backend of BASE_DN refuses the writes of clients because this join stopped them: an
# operator's read-only backend leaves $REJOIN_PENDING empty, and a hold whose dsconfig failed
# named the backend without changing its mode
writes_held() {
  [ -s "$REJOIN_PENDING" ] && [ "$(data_backend | awk '{ print $2 }')" = internal-only ]
}
 
# a server that rejoins keeps $REJOIN_PENDING, and so stays unready on its next start too,
# until its clients may write again and its peers see it ready. A dsconfig or a publish that
# fails is tried again here, as long as the retries last: the join that got this far would
# otherwise end, and only a later start would enable, or initialize, all over again
leave_rejoin() {
  local i
  [ -f "$REJOIN_PENDING" ] || return 0
  for i in $(seq 1 "$REPLICATION_RETRY_COUNT"); do
    if release_writes && publish_state ready; then
      rm -f "$REJOIN_PENDING"
      return 0
    fi
    if [ "$i" -lt "$REPLICATION_RETRY_COUNT" ]; then
      pause "join: this server is not out of its rejoin yet ($i of $REPLICATION_RETRY_COUNT)"
    fi
  done
  if writes_held; then
    echo "join: could not let the writes of clients in again after $REPLICATION_RETRY_COUNT attempts, this container will not report itself healthy and serves its data read-only until a later start does"
  else
    echo "join: could not publish this server ready after $REPLICATION_RETRY_COUNT attempts, this container will not report itself healthy until a later start does"
  fi
  return 1
}
 
joined() {
  cleanup_departed
  leave_rejoin || exit 1
  # a replication server published itself rejoining until now, on the ready road too
  if [ "$ROLE" = replication ] && ! publish_state ready; then
    echo "join: could not publish this server ready, this container will not report itself healthy until a later start does"
    exit 1
  fi
  touch "$BOOTSTRAP_COMPLETE"
  echo "join: this server is a member of the replication topology, the health check may probe it"
  repair_replication_servers
}
 
# the peers learn that this server holds the data before anything else changes: a seed whose
# state never reached them would be healthy while every pending peer skips it
seed() { # <why>
  echo "join: $1, seeding it with this server's data"
  publish_state ready || return 1
  leave_rejoin || return 1
  rm -f "$INITIALIZE_PENDING"
  touch "$BOOTSTRAP_COMPLETE"
}
 
# what giving up means depends on whether the container already reports itself healthy: a
# volume that holds the data of the topology does so from the start, until a reset of its
# replication took that back - and the writes of its clients, where the reset stopped them
give_up() {
  if [ -f "$BOOTSTRAP_COMPLETE" ] && [ "$ROLE" = replication ]; then
    echo "join: could not join the replication topology after $REPLICATION_RETRY_COUNT attempts, this replication server stays apart from it until a later start joins it"
  elif [ -f "$BOOTSTRAP_COMPLETE" ]; then
    echo "join: could not join the replication topology after $REPLICATION_RETRY_COUNT attempts, this server keeps serving its data unreplicated until a later start joins it"
  elif writes_held; then
    echo "join: could not join the replication topology after $REPLICATION_RETRY_COUNT attempts, this container will not report itself healthy and serves its data read-only until a later start joins it"
  else
    echo "join: could not join the replication topology after $REPLICATION_RETRY_COUNT attempts, this container will not report itself healthy"
  fi
  exit 1
}
 
echo "join: waiting for the server on the administration connector"
for i in $(seq 1 150); do
  server_up && own_config=$(replication_config localhost) && break
  if [ "$i" -eq 150 ]; then
    echo "join: the server did not come up, or does not take ROOT_PASSWORD any more, giving up"
    exit 1
  fi
  sleep 2
done
 
# The role this server has, which every step below goes by: the one setup.sh set the volume
# up with, whatever REPLICATION_ROLE says now - a replication server has no backend for
# BASE_DN, a server with one holds data, and a later start in another role is told rather than
# left to believe it took effect. A volume set up by an image before roles has no role
# recorded: its replication configuration tells it, and REPLICATION_ROLE where that does not -
# a replication server without a domain is the replication server of the one-shot srs, when
# REPLICATION_ROLE says so, and a combined server whose enable stopped half-way otherwise. One
# without a backend for BASE_DN as well holds no data whatever REPLICATION_ROLE says: only
# this image sets a volume up so, for a replication server. The role found is recorded, so
# that it stays what it is from then on.
legacy_role() { # <replication_config output>
  case $(config_shape "$1") in
    combined) echo combined ;;
    directory) echo directory ;;
    server)
      if [ "$REPLICATION_ROLE" = replication ] || ! grep -qx backend <<<"$1"; then
        echo replication
      else
        echo combined
      fi
      ;;
    *)
      if [ -f "$REJOIN_PENDING" ]; then
        # only the join of an image before roles took a volume down, and it ran combined
        # servers only
        echo combined
      elif grep -qx backend <<<"$1" && [ "$REPLICATION_ROLE" = replication ]; then
        echo "join: this server holds a backend for $BASE_DN, which a replication server does not, and joins as a combined server; a replication server starts from an empty volume" >&2
        echo combined
      else
        echo "$REPLICATION_ROLE"
      fi
      ;;
  esac
}
if [ -s "$REPLICATION_ROLE_FILE" ]; then
  ROLE=$(cat "$REPLICATION_ROLE_FILE")
  case $ROLE in
    combined | directory | replication) ;;
    *)
      echo "join: $REPLICATION_ROLE_FILE holds $ROLE, which is not one of combined, directory or replication, this server does not join"
      exit 1 ;;
  esac
else
  ROLE=$(legacy_role "$own_config")
  printf '%s\n' "$ROLE" >"$REPLICATION_ROLE_FILE" \
    || echo "join: could not record the role $ROLE in $REPLICATION_ROLE_FILE, the next start reads it again"
fi
if [ "$ROLE" != "$REPLICATION_ROLE" ]; then
  echo "join: this server is a $ROLE server and keeps that role, REPLICATION_ROLE=$REPLICATION_ROLE does not change it"
fi
if [ "$ROLE" != replication ] && ! grep -qx backend <<<"$own_config"; then
  echo "join: this server holds no backend for $BASE_DN, which a $ROLE server needs: its bootstrap did not create one"
fi
 
# Only the first peer of REPLICATION_PEERS that holds data may seed a topology: a replication
# server has none to seed it with, and one listed ahead of the others is passed over. Asked
# only where a seed is weighed, and only of the peers listed ahead of this one, each by the
# role it publishes; one that does not answer, or does not publish one yet, counts as holding
# data, so that two servers never both take themselves for the first. A role stays with its
# volume, so each answer is kept: a peer ahead that publishes another role holds data for
# good, and one that publishes replication is not asked again.
AHEAD_HOLDS_DATA=no
AHEAD_REPLICATION=
may_seed() {
  local peer config
  [ "$ROLE" = replication ] && return 1
  [ "$AHEAD_HOLDS_DATA" = yes ] && return 1
  for peer in "${PEERS[@]}"; do
    is_self "$peer" && return 0
    [[ " $AHEAD_REPLICATION " == *" $peer "* ]] && continue
    config=$(replication_config "$peer") || return 1
    grep -q '^published' <<<"$config" || return 1
    if [ "$(peer_role "$config")" != replication ]; then
      AHEAD_HOLDS_DATA=yes
      return 1
    fi
    AHEAD_REPLICATION="$AHEAD_REPLICATION $peer"
  done
  return 1
}
 
# Every round on both roads starts here: a replication configuration that the peers no longer
# register is taken down (see reset_replication). So is one that the peers register but the
# cn=admin data of this server does not: an enable that stopped half-way - its initialize of
# cn=admin data failed, say - leaves BASE_DN replicated and this server registered at the
# peer, and every enable after it exits 5, "already replicated", without registering it here.
# Fails when a reset is due and could not be done in this round, which then enables nothing -
# an enable registers nobody before it.
reset_if_unregistered() { # <the state to publish meanwhile>
  local rc=0
  holds_own_part || return 0
  if ! registered_with_peers; then
    reset_replication "$1" "the peers no longer register this server"
    return
  fi
  registered_here || rc=$?
  if [ "$rc" -eq 1 ]; then
    reset_replication "$1" "the peers register this server but its own cn=admin data does not"
  fi
}
 
if [ ! -f "$INITIALIZE_PENDING" ]; then
  # This volume holds the data of the topology, or is the one that seeds it: it publishes
  # that - unless a reset took its replication down and it has not rejoined yet, also on an
  # earlier start. A replication server holds no data: what a peer may join through is its
  # membership, and a peer that enabled through it before a reset took it out would be
  # registered in a registry of the two of them. So it publishes itself rejoining until a
  # round below finds it a member - not pending, which would count it among the peers that
  # wait for data and let a fresh seed go ahead next to the topology it serves
  if [ -f "$REJOIN_PENDING" ] || [ "$ROLE" = replication ]; then
    publish_state rejoining
  else
    publish_state ready
  fi
  for i in $(seq 1 "$REPLICATION_RETRY_COUNT"); do
    if ! reset_if_unregistered rejoining; then
      : # the next round tries the reset again
    elif is_member; then
      echo "join: already a member of the replication topology"
      joined
      exit
    else
      # only through a peer that holds the topology, as on the pending road: a rejoining peer
      # has no replication of its own, a pending one or one that still bootstraps none yet, and
      # an enable with any of them registers the two in a registry of their own - which counts
      # as membership, a peer of the pair lists this server - beside the survivors, whom
      # nothing ever adds to it
      for peer in "${PEERS[@]}"; do
        is_self "$peer" && continue
        state=$(peer_state "$peer")
        if ! trusted "$state"; then
          echo "join: $peer is $state, not a peer to join through"
          continue
        fi
        echo "join: enabling replication with $peer"
        enable_through "$peer"
        rc=$?
        if member_of_topology; then
          echo "join: joined the replication topology through $peer"
          # a replication server holds no generation ID of BASE_DN to compare with, neither
          # this one nor the peer joined through
          if [ "$ROLE" != replication ]; then
            replicates_base_dn "$peer" || peer=$(trusted_source) || peer=
            [ -z "$peer" ] || cross_check "$peer" "; replication will not flow until this server or that one is initialized by hand"
          fi
          joined
          exit
        fi
        [ "$rc" -eq 75 ] || echo "join: dsreplication enable with $peer exited with $rc and membership is not there"
      done
    fi
    # a list without a single other server leaves nothing to retry against, and peers that
    # all wait for data join through this one rather than the other way round
    if may_seed && all_others_pending; then
      seed "no other peer holds the data of the topology" || exit 1
      exit 0
    fi
    if [ "$i" -lt "$REPLICATION_RETRY_COUNT" ]; then
      pause "join: no peer joined this server yet ($i of $REPLICATION_RETRY_COUNT)"
    fi
  done
  # the same rule as at the end of the pending road below
  if may_seed && ! any_other_may_hold_data; then
    seed "no topology found after $REPLICATION_RETRY_COUNT attempts" || exit 1
    exit 0
  fi
  give_up
fi
 
# This volume was bootstrapped and never received the data of the topology: it joins and
# initializes only through a peer that holds that data, and only the first peer that holds
# data (see may_seed) may decide that nobody does. Its membership is asked of the peers as on
# the road above: a volume that enabled once but never completed its initialize may have been
# removed by the survivors while it was away, and its own cn=admin data still registers it
# until their delete reaches it - after which every enable registers nobody, as
# reset_replication describes.
publish_state pending
member=no
for i in $(seq 1 "$REPLICATION_RETRY_COUNT"); do
  member=no
  reset_due=no
  if ! reset_if_unregistered pending; then
    reset_due=yes
  elif is_member; then
    member=yes
  fi
  if [ "$member" = no ] && [ "$reset_due" = no ]; then
    for peer in "${PEERS[@]}"; do
      is_self "$peer" && continue
      state=$(peer_state "$peer")
      if ! trusted "$state"; then
        echo "join: $peer is $state, not a peer to join through"
        continue
      fi
      echo "join: enabling replication with $peer"
      enable_through "$peer"
      rc=$?
      if member_of_topology; then
        member=yes
        echo "join: joined the replication topology through $peer"
        break
      fi
      [ "$rc" -eq 75 ] || echo "join: dsreplication enable with $peer exited with $rc and membership is not there"
    done
  fi
  if [ "$member" = yes ] && [ "$ROLE" = replication ]; then
    # a replication server holds no data that an initialize would bring: membership is all
    # it waits for. Published before the marker goes, as after an initialize
    publish_state ready || exit 1
    rm -f "$INITIALIZE_PENDING"
    joined
    exit
  elif [ "$member" = yes ]; then
    source=$(trusted_source)
    if [ -n "$source" ]; then
      echo "join: initializing from $source"
      initialize_from "$source"
      rc=$?
      if [ "$rc" -eq 0 ]; then
        # published before the marker goes, so that the marker is never gone while the peers
        # still see this server pending; the next start initializes it again
        publish_state ready || exit 1
        rm -f "$INITIALIZE_PENDING"
        cross_check "$source" " after initialize"
        joined
        exit
      fi
      echo "join: initialize from $source exited with $rc"
    elif may_seed && all_others_pending; then
      seed "every other peer waits for the data of the topology as well" || exit 1
      joined
      exit 0
    else
      echo "join: no peer holds the data of the topology yet"
    fi
  elif may_seed && all_others_pending; then
    seed "every other peer waits for the data of the topology as well" || exit 1
    exit 0
  fi
  if [ "$i" -lt "$REPLICATION_RETRY_COUNT" ]; then
    pause "join: not joined and initialized yet ($i of $REPLICATION_RETRY_COUNT)"
  fi
done
 
# Only the first peer that holds data may decide that there is no topology to join and that
# its own data seeds one, and only while no peer that could hold the topology's data answers;
# anyone else staying unhealthy is what surfaces a lost topology instead of forking it
if [ "$member" = no ] && may_seed && ! any_other_may_hold_data; then
  seed "no topology found after $REPLICATION_RETRY_COUNT attempts" || exit 1
  exit 0
fi
 
give_up