Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions solution-base/images/Makefile
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
BITNAMI_REMOTE := bitnami-containers
BITNAMI_REPO := https://github.com/bitnami/containers.git
BITNAMI_UPSTREAM_MAIN_REF := $(BITNAMI_REMOTE)/main

IMAGES := mongodb-sharded mongodb-exporter os-shell

BITNAMI_mongodb_sharded_PATH := 8.0/debian-12
# Latest commit on upstream main known to still contain
# bitnami/mongodb-sharded/8.0/debian-12.
BITNAMI_mongodb_sharded_REF := 48a109547d39cd8cf8a5d4058d832ecb5844829e
BITNAMI_mongodb_sharded_REF := 657585595c550d4dc107a4e6cd3a598a9d284eec

BITNAMI_mongodb_exporter_PATH := 0/debian-12
BITNAMI_mongodb_exporter_REF := $(BITNAMI_UPSTREAM_MAIN_REF)
BITNAMI_mongodb_exporter_REF := main

BITNAMI_os_shell_PATH := 12/debian-12
BITNAMI_os_shell_REF := $(BITNAMI_UPSTREAM_MAIN_REF)
BITNAMI_os_shell_REF := main

.PHONY: create-remote fetch-remote vendor-sync $(addprefix vendor-sync-,$(IMAGES)) $(addprefix update-vendor-branch-,$(IMAGES))
.PHONY: create-remote vendor-sync

normalize = $(subst -,_,$1)
bitnami_path = $(BITNAMI_$(call normalize,$1)_PATH)
Expand All @@ -25,15 +24,20 @@ vendor_branch = vendor/$1/$(call bitnami_path,$1)
create-remote:
@git remote get-url $(BITNAMI_REMOTE) >/dev/null 2>&1 || git remote add $(BITNAMI_REMOTE) $(BITNAMI_REPO)

fetch-remote: create-remote
# Fetch full history from Bitnami main so subtree split can see full subtree history.
git fetch $(BITNAMI_REMOTE) main

update-vendor-branch-%: fetch-remote
update-vendor-branch-%: create-remote
# Shallow-fetch only the pinned ref; full upstream history is unnecessary for a --squash merge.
git fetch --depth 1 --filter=blob:none $(BITNAMI_REMOTE) $(call bitnami_ref,$*)
-git branch -D $(call vendor_branch,$*)
git subtree split --prefix=bitnami/$*/$(call bitnami_path,$*) $(call bitnami_ref,$*) -b $(call vendor_branch,$*)
sha=$$(git rev-parse FETCH_HEAD) && \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for Git, this is actually $(call bitnami_ref,$*).
So best to do it the other way:

sha=$(call bitnami_ref,$*) &&
git fetch --depth 1 --filter=blob:none $(BITNAMI_REMOTE) $${sha} &&

wt=$$(mktemp -d) && \
trap 'git worktree remove --force "$$wt" 2>/dev/null' EXIT && \
git worktree add --no-checkout --detach "$$wt" "$$sha" && \
git -C "$$wt" sparse-checkout set --no-cone bitnami/$*/$(call bitnami_path,$*) && \
git -C "$$wt" checkout && \
Comment on lines +33 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need a checkout?

git -C "$$wt" subtree split --prefix=bitnami/$*/$(call bitnami_path,$*) "$$sha" -b $(call vendor_branch,$*)

vendor-sync-%: update-vendor-branch-%
cd "$$(git rev-parse --show-toplevel)" && \
git subtree merge --prefix=solution-base/images/$*/debian-12 $(call vendor_branch,$*) --squash

vendor-sync: $(addprefix vendor-sync-,$(IMAGES))
Original file line number Diff line number Diff line change
Expand Up @@ -910,14 +910,17 @@ mongodb_is_secondary_node_ready() {
local -r port="${2:?port is required}"

debug "Waiting for the node to be marked as secondary"
# mongosh prints a connection banner holding 'directConnection=true', so matching
# on 'true' would always succeed. Match a dedicated sentinel instead, built at
# runtime so the positive value never appears verbatim in the submitted script.
result=$(
mongodb_execute_print_output "$MONGODB_INITIAL_PRIMARY_ROOT_USER" "$MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD" "admin" "$MONGODB_INITIAL_PRIMARY_HOST" "$MONGODB_INITIAL_PRIMARY_PORT_NUMBER" <<EOF
rs.status().members.filter(m => m.name === '$node:$port' && m.stateStr === 'SECONDARY').length === 1
print("IS_SECONDARY_" + (rs.status().members.some(m => m.name === '$node:$port' && m.stateStr === 'SECONDARY') ? "YES" : "NO"))
EOF
)
debug "$result"

grep -q "true" <<<"$result"
grep -q "IS_SECONDARY_YES" <<<"$result"
}

########################
Expand Down Expand Up @@ -951,6 +954,35 @@ EOF
grep -q "ok: 1" <<<"$result"
}

########################
# Get if secondary node already has voting rights
Comment thread
DarkIsDude marked this conversation as resolved.
# Globals:
# MONGODB_*
# Arguments:
# $1 - node
# $2 - port
# Returns:
# Boolean
#########################
mongodb_secondary_node_has_voting_rights() {
local -r node="${1:?node is required}"
local -r port="${2:?port is required}"
local result

debug "Checking voting rights of the node"
# mongosh prints a connection banner holding 'directConnection=true', so matching
# on 'true' would always succeed. Match a dedicated sentinel instead, built at
# runtime so the positive value never appears verbatim in the submitted script.
result=$(
mongodb_execute_print_output "$MONGODB_INITIAL_PRIMARY_ROOT_USER" "$MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD" "admin" "$MONGODB_INITIAL_PRIMARY_HOST" "$MONGODB_INITIAL_PRIMARY_PORT_NUMBER" <<EOF
print("HAS_VOTES_" + (rs.conf().members.some(m => m.host === '$node:$port' && m.votes > 0 && m.priority > 0) ? "YES" : "NO"))
EOF
)
debug "$result"

grep -q "HAS_VOTES_YES" <<<"$result"
}

########################
# Get if hidden node is pending
# Globals:
Expand Down Expand Up @@ -1187,7 +1219,15 @@ mongodb_configure_secondary() {
exit 1
fi
mongodb_wait_confirmation "$node" "$port"
fi

# Grant voting rights to the node if it does not have them yet. This must be
# done even when the node is already in the cluster: a previous attempt may
# have added it with votes/priority 0 and failed (or been restarted) before
# granting voting rights, leaving the node stuck without them.
if mongodb_secondary_node_has_voting_rights "$node" "$port"; then
info "Node already has voting rights"
else

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will not behave properly when we have 9-replicas, where 2 nodes are always "non voting". This is expected, and we must not try to change that (in particular, the voting nodes must be in precisely the expected DC for proper redundancy)

→ the change will make the startup slower on these extra secondaries -(re-)trying to make them voting-, possibly with the extra risk of changing the set of voters...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DarkIsDude did you try that case?

Looking at the code below, it seems that the script would exit if it fails to configure secondary node voting: does that function already silently handle the case where we reached the max number of voters already?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — tested on a real 9-replica shard, both with the current development/2.15 image and with this branch. Short answer to your question: no, it is not silently handled — it exits. But that is pre-existing behaviour, not something this PR introduces.

First: your review caught a real bug, just not the one we were discussing. mongodb_secondary_node_has_voting_rights() grepped mongosh's output for true, and mongosh always prints a connection banner containing directConnection=true. So the check always returned true, the grant branch became dead code, and no secondary ever got voting rights. Observed directly: a fresh secondary joined with a free voter slot available and stayed at votes=0 forever (9 members / 6 voters). By extension a fresh install would leave only pod-0 — which self-initiates — with a vote. Fixed using an explicit HAS_VOTES_YES/HAS_VOTES_NO sentinel. Re-tested after the fix: same scenario now yields votes=1 priority=1 → 7 voters.

Secondly: "the extra risk of changing the set of voters": The only path where this branch differs from development/2.15 is data dir empty and node already in rs.conf() — i.e. PVC loss on an existing member.

Last one: "does that function silently handle reaching the max number of voters?" No. Reproduced, scaling 3 → 9:

mongod:  Replica set configuration contains 8 voting members, but must be at least 1 and no more than 7
pod-7:   ERROR ==> Secondary node did not get marked as secondary     (after 24 × 5s of retries)
         → exit 1 → container restart

It does recover: on restart the data dir is no longer empty, config is skipped, and the member settles as a working non-voting secondary. Net cost is one crash + ~3 min per extra member, and the final topology is the expected 9 members / 7 voters.
This branch behaves identically (9 members / 7 voters, one crash each on pods 7 and 8), with a more accurate message: Secondary node did not get granted voting rights rather than the old, misleading "did not get marked as secondary".
Happy to add a members.filter(m => m.votes > 0).length < 7 guard to skip the doomed promotion entirely if you'd like that in this PR. Put the behaviour is unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to add a members.filter(m => m.votes > 0).length < 7 guard to skip the doomed promotion entirely if you'd like that in this PR. Put the behaviour is unchanged.

Ack for the unchanged behavior, but I guess it's best to fix it, right?
(should probably be upstreamed as well)

# Ensure that secondary nodes do not count as voting members until they are fully initialized
# https://docs.mongodb.com/manual/reference/method/rs.add/#behavior
if ! retry_while "mongodb_is_secondary_node_ready $node $port" "$MONGODB_INIT_RETRY_ATTEMPTS" "$MONGODB_INIT_RETRY_DELAY"; then
Expand All @@ -1198,17 +1238,16 @@ mongodb_configure_secondary() {
# Grant voting rights to node
# https://docs.mongodb.com/manual/tutorial/modify-psa-replica-set-safely/
if ! retry_while "mongodb_configure_secondary_node_voting $node $port" "$MONGODB_INIT_RETRY_ATTEMPTS" "$MONGODB_INIT_RETRY_DELAY"; then
error "Secondary node did not get marked as secondary"
error "Secondary node did not get granted voting rights"
exit 1
fi
fi

# Mark node as readable. This is necessary in cases where the PVC is lost
if is_boolean_yes "$MONGODB_SET_SECONDARY_OK"; then
mongodb_execute_print_output "$MONGODB_INITIAL_PRIMARY_ROOT_USER" "$MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD" "admin" <<EOF
# Mark node as readable. This is necessary in cases where the PVC is lost
if is_boolean_yes "$MONGODB_SET_SECONDARY_OK"; then
mongodb_execute_print_output "$MONGODB_INITIAL_PRIMARY_ROOT_USER" "$MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD" "admin" <<EOF
rs.secondaryOk()
EOF
fi

fi
}

Expand Down
136 changes: 136 additions & 0 deletions tests/scripts/test_libmongodb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Regression tests for the vendored bitnami ``libmongodb.sh``.

These guard against a class of bug where a helper decides a boolean by
substring-matching mongosh's output. mongosh always prints a connection
banner containing ``directConnection=true`` on stdout, so ``grep -q "true"``
matches unconditionally and the helper can never return false.

That is exactly how ``mongodb_secondary_node_has_voting_rights`` shipped
(bitnami/containers#95156): the voting-rights grant became unreachable and
secondaries stayed at votes:0/priority:0 forever.
"""

import re
import subprocess
from pathlib import Path

import pytest

LIBMONGODB = (
Path(__file__).resolve().parents[2]
/ "solution-base"
/ "images"
/ "mongodb-sharded"
/ "debian-12"
/ "rootfs"
/ "opt"
/ "bitnami"
/ "scripts"
/ "libmongodb.sh"
)

# Verbatim mongosh 2.9.2 preamble. The ``directConnection=true`` substring is
# the whole point of these tests: a fixture without it would happily pass on
# the broken implementation.
MONGOSH_BANNER = (
"Current Mongosh Log ID:\t6a7ddf270f5d3c6ed07e2c0c\n"
"Connecting to:\t\tmongodb://h:27017/admin"
"?directConnection=true&appName=mongosh+2.9.2\n"
"Using MongoDB:\t\t8.0.13\n"
"Using Mongosh:\t\t2.9.2\n"
"\n"
)


@pytest.fixture(scope="module")
def source() -> str:
return LIBMONGODB.read_text(encoding="utf-8")


def extract_function(name: str, source: str) -> str:
"""Return the shell source of ``name``.

``libmongodb.sh`` sources its dependencies from absolute ``/opt/bitnami``
paths, so it cannot be sourced outside the image; the function under test
is lifted out instead.
"""
match = re.search(rf"^{re.escape(name)}\(\) \{{$", source, re.MULTILINE)
assert match is not None, f"{name} not found in {LIBMONGODB.name}"

body = []
for line in source[match.start():].splitlines(keepends=True):
body.append(line)
if line.rstrip("\n") == "}":
return "".join(body)
raise AssertionError(f"unterminated function {name}")


def call_with_mongosh_output(function: str, call: str, answer: str) -> int:
"""Run ``call`` with ``mongodb_execute_print_output`` stubbed out.

The stub replays what mongosh actually writes to stdout: the connection
banner followed by the evaluated result.
"""
script = f"""
set -uo pipefail
debug() {{ :; }}
mongodb_execute_print_output() {{
cat <<'MONGOSH_OUTPUT'
{MONGOSH_BANNER}{answer}
MONGOSH_OUTPUT
}}
MONGODB_INITIAL_PRIMARY_ROOT_USER=root
MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=password
MONGODB_INITIAL_PRIMARY_HOST=h
MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017
{function}
{call}
"""
return subprocess.run(["bash", "-c", script], capture_output=True, text=True).returncode


@pytest.mark.parametrize(
("answer", "expected_rc"),
[("HAS_VOTES_YES", 0), ("HAS_VOTES_NO", 1)],
)
def test_has_voting_rights_reflects_the_query_result(source, answer, expected_rc):
"""A node without votes must be reported as such, banner notwithstanding."""
rc = call_with_mongosh_output(
extract_function("mongodb_secondary_node_has_voting_rights", source),
"mongodb_secondary_node_has_voting_rights node 27017",
answer,
)
assert rc == expected_rc


@pytest.mark.parametrize(
("answer", "expected_rc"),
[("IS_SECONDARY_YES", 0), ("IS_SECONDARY_NO", 1)],
)
def test_is_secondary_node_ready_reflects_the_query_result(source, answer, expected_rc):
rc = call_with_mongosh_output(
extract_function("mongodb_is_secondary_node_ready", source),
"mongodb_is_secondary_node_ready node 27017",
answer,
)
assert rc == expected_rc


def test_no_helper_matches_a_bare_boolean_in_mongosh_output(source):
"""Forbid the idiom that caused the regression.

A bare ``true``/``false`` match is always satisfied by the connection
banner. Deciding a boolean therefore requires either a dedicated sentinel
or a pattern anchored on the mongosh prompt, as
``mongodb_is_primary_node_up`` already does.
"""
offenders = [
(number, line.strip())
for number, line in enumerate(source.splitlines(), start=1)
if re.search(r"""grep\s+-q\w*\s+(["'])(true|false)\1""", line)
]
assert not offenders, (
"mongosh prints 'directConnection=true' in its connection banner, so "
"these matches always succeed:\n"
+ "\n".join(f" {LIBMONGODB.name}:{n}: {t}" for n, t in offenders)
)
Loading