#!/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 # (-0. ... -N-1.), 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 -N...svc. # is listed as -N.), 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. 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. 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} # 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" # 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() { # 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 # 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() { # [