feat: add optional MongoDB replica deployment (#765)

This commit is contained in:
Thomas Durieux
2026-07-30 02:28:19 +02:00
committed by GitHub
parent a8733af4f4
commit debd83c079
11 changed files with 512 additions and 7 deletions
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
primary_files=(
-f "$repository_root/docker-compose.yml"
-f "$repository_root/docker-compose.replica-primary.yml"
)
secondary_files=(
-f "$repository_root/docker-compose.replica-secondary.yml"
)
compose_prefix=(docker compose)
if [[ -n "${COMPOSE_ENV_FILE:-}" ]]; then
compose_prefix+=(--env-file "$COMPOSE_ENV_FILE")
fi
keyfile="${MONGO_REPLICA_KEYFILE:-$repository_root/secrets/mongo-replica-keyfile}"
if [[ "$keyfile" != /* ]]; then
keyfile="$repository_root/${keyfile#./}"
fi
usage() {
cat <<'USAGE'
Usage:
./scripts/mongodb-replica.sh generate-key
./scripts/mongodb-replica.sh primary-up <primary-hostname:port>
./scripts/mongodb-replica.sh secondary-up
./scripts/mongodb-replica.sh add-secondary <secondary-hostname:port> [delay-seconds]
./scripts/mongodb-replica.sh status
Environment:
COMPOSE_ENV_FILE Optional Compose environment file.
MONGO_REPLICA_KEYFILE Shared keyfile path (default: ./secrets/mongo-replica-keyfile).
The primary and secondary hostnames must resolve from both MongoDB servers.
The remote member is added hidden, priority 0, and non-voting so a WAN outage
cannot block production majority writes. Set delay-seconds to 0 for no delay.
USAGE
}
validate_host() {
if [[ ! "$1" =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]]; then
echo "Expected a resolvable hostname and port, for example mongo-primary.internal:27017" >&2
exit 2
fi
}
validate_delay() {
if [[ ! "$1" =~ ^[0-9]+$ ]]; then
echo "Delay must be a non-negative number of seconds" >&2
exit 2
fi
}
require_keyfile() {
if [[ ! -s "$keyfile" ]]; then
echo "Replica keyfile not found: $keyfile" >&2
echo "Run ./scripts/mongodb-replica.sh generate-key first." >&2
exit 2
fi
}
primary_compose() {
"${compose_prefix[@]}" "${primary_files[@]}" "$@"
}
secondary_compose() {
"${compose_prefix[@]}" "${secondary_files[@]}" "$@"
}
mongo_eval() {
local javascript="$1"
primary_compose exec -T -e "MONGO_RS_EVAL=$javascript" mongodb sh -eu -c \
'mongosh --quiet \
--username="$MONGO_INITDB_ROOT_USERNAME" \
--password="$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase=admin \
--eval "$MONGO_RS_EVAL"'
}
wait_for_primary_container() {
local attempt
for attempt in $(seq 1 60); do
if primary_compose exec -T mongodb sh -eu -c \
'mongosh --quiet \
--username="$MONGO_INITDB_ROOT_USERNAME" \
--password="$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase=admin \
--eval "db.adminCommand({ ping: 1 }).ok"' >/dev/null 2>&1; then
return
fi
sleep 2
done
echo "MongoDB did not become ready within 120 seconds" >&2
exit 1
}
command="${1:-}"
case "$command" in
generate-key)
if [[ -e "$keyfile" ]]; then
echo "Refusing to overwrite existing keyfile: $keyfile" >&2
exit 2
fi
mkdir -p "$(dirname "$keyfile")"
umask 077
openssl rand -base64 756 > "$keyfile"
chmod 600 "$keyfile"
echo "Created $keyfile"
echo "Copy this exact file securely to the secondary server."
;;
primary-up)
primary_host="${2:-}"
validate_host "$primary_host"
require_keyfile
primary_compose up -d mongodb
wait_for_primary_container
mongo_eval "
try {
const status = rs.status();
print('Replica set already initialized as ' + status.set);
} catch (error) {
if (error.code !== 94 && error.codeName !== 'NotYetInitialized') throw error;
printjson(rs.initiate({
_id: 'rs0',
members: [{ _id: 0, host: '$primary_host', priority: 1, votes: 1 }]
}));
}
"
;;
secondary-up)
require_keyfile
secondary_compose up -d mongodb-secondary
;;
add-secondary)
secondary_host="${2:-}"
delay="${3:-3600}"
validate_host "$secondary_host"
validate_delay "$delay"
mongo_eval "
const host = '$secondary_host';
const existing = rs.conf().members.find((member) => member.host === host);
if (existing) {
print('Secondary already configured: ' + host);
} else {
printjson(rs.add({
host,
priority: 0,
votes: 0,
hidden: true,
secondaryDelaySecs: $delay
}));
}
"
;;
status)
mongo_eval "
printjson(rs.status().members.map((member) => ({
name: member.name,
state: member.stateStr,
health: member.health,
replicationTime: member.optimeDate
})));
"
;;
*)
usage
[[ -n "$command" ]] && exit 2
;;
esac