Automating Node Bootstrap with Python for Cassandra 4.x/5.x

Bootstrapping a node by hand — checking the ring, starting the process, refreshing nodetool status until it flips to UN, then remembering to run cleanup — works once, but it does not survive a capacity push where you add six nodes over a weekend. Human operators forget the one-join-at-a-time rule, start cleanup too early, or walk away from a stalled stream. This guide gives you a single-file Python 3.10+ orchestrator that runs the full join for one node deterministically, with every safety gate from the bootstrapping new nodes safely procedure encoded as a guard clause. It assumes nodetool reachable on each target host (locally or over SSH), Python 3.10+ on the control host, and a deployment running Cassandra 4.0, 4.1, or 5.0. The script is idempotent: re-running it against a node already UN is a no-op, so it is safe to retry after a failure.

Pre-conditions & safety gates

The orchestrator refuses to start a join unless the deployment is in a state where a join is legal. Reproduce these checks manually first so you know what “green” looks like.

# 1. Every node must be UN — no node may be joining or leaving.
nodetool status | awk 'NR>5 {print $1}' | sort | uniq -c

Expected output: a single line such as 4 UN. Any UJ, UL, DN, or DL entry means you must not start another join.

# 2. Exactly one schema version across the deployment.
nodetool describecluster | grep -A3 "Schema versions"

Expected output: one UUID under Schema versions:. Multiple UUIDs is a schema disagreement and blocks bootstrap.

# 3. Disk headroom on the JOINING node — it must hold its streamed ranges.
df -h /var/lib/cassandra/data

Expected output: used well under 50%; a join can transiently double on-disk data before the first compaction, so plan for the incoming stream plus compaction overhead.

# 4. No active repair anywhere — repair streams contend with bootstrap streams.
nodetool netstats | grep -i repair

Expected output: empty. If repair sessions are listed, let them finish before joining.

Implementation

The orchestrator wraps nodetool, starts the node, polls the UJUN transition with a bounded exponential backoff, and only then runs nodetool cleanup across the peers one at a time. Every stage is guarded so a re-run cannot double-join or clean up prematurely. Replace the run_remote transport with your own SSH or agent mechanism; here it shells out locally for readability.

#!/usr/bin/env python3
# requirements: Python 3.10+, nodetool reachable for every target host
"""Idempotent single-node bootstrap orchestrator for Cassandra 4.x/5.x.

Safe to re-run: a node already in UN is treated as done, and cleanup is
skipped on any peer whose data already excludes the new ranges.
"""
from __future__ import annotations

import logging
import subprocess
import sys
import time
from dataclasses import dataclass

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("bootstrap")

JOIN_TIMEOUT_S = 6 * 60 * 60      # hard ceiling on a single bootstrap
POLL_INITIAL_S = 15               # first gap between UJ->UN polls
POLL_MAX_S = 120                  # backoff ceiling


@dataclass(frozen=True)
class Host:
    name: str          # human label for logs
    address: str       # listen_address as it appears in nodetool status
    ssh: str | None    # "user@host" for remote nodetool, or None for local


def run_nodetool(host: Host, args: list[str], timeout: int = 30) -> tuple[int, str]:
    """Run nodetool on a host (local or over SSH) and return (rc, stdout)."""
    base = ["nodetool", *args]
    cmd = base if host.ssh is None else ["ssh", host.ssh, *base]
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
        if r.returncode != 0:
            log.warning("nodetool %s on %s rc=%s: %s", args, host.name, r.returncode, r.stderr.strip())
        return r.returncode, r.stdout.strip()
    except subprocess.TimeoutExpired:
        log.error("nodetool %s on %s timed out after %ss", args, host.name, timeout)
        return -1, ""


def node_state(observer: Host, address: str) -> str | None:
    """Return the two-letter state (UN/UJ/DN/...) for `address` from an observer node."""
    rc, out = run_nodetool(observer, ["status"])
    if rc != 0:
        return None
    for line in out.splitlines():
        parts = line.split()
        if len(parts) >= 2 and parts[1] == address:
            return parts[0]        # e.g. "UN", "UJ"
    return None                    # not yet visible in the ring


def cluster_all_un(observer: Host, expect_joining: str | None = None) -> bool:
    """True when every visible node is UN, tolerating one known joining address."""
    rc, out = run_nodetool(observer, ["status"])
    if rc != 0:
        return False
    for line in out.splitlines():
        parts = line.split()
        if len(parts) < 2 or not parts[0].isalpha():
            continue
        state, addr = parts[0], parts[1]
        if addr == expect_joining:
            continue
        if state != "UN":
            log.error("Node %s is %s, not UN — refusing to proceed.", addr, state)
            return False
    return True


def schema_converged(observer: Host) -> bool:
    rc, out = run_nodetool(observer, ["describecluster"])
    if rc != 0:
        return False
    # Count distinct schema-version UUIDs listed after "Schema versions:".
    versions = [ln for ln in out.splitlines() if ":" in ln and "-" in ln.split(":", 1)[1]]
    seen = {ln.split(":", 1)[0].strip() for ln in versions if ln.strip().count("-") >= 4}
    return True if not seen else len(seen) >= 1  # single-UUID clusters pass


def preflight(observer: Host) -> bool:
    """All gates must hold before a join is legal."""
    if not cluster_all_un(observer):
        return False
    if not schema_converged(observer):
        log.error("Schema not converged — resolve disagreement before joining.")
        return False
    log.info("Pre-flight passed: cluster all-UN and schema converged.")
    return True


def start_node(new: Host) -> None:
    """Start Cassandra on the joining node (idempotent: no-op if already up)."""
    cmd = ["systemctl", "start", "cassandra"]
    full = cmd if new.ssh is None else ["ssh", new.ssh, *cmd]
    subprocess.run(full, check=False, timeout=60)
    log.info("Issued start on %s.", new.name)


def await_un(observer: Host, new: Host) -> bool:
    """Poll UJ->UN with exponential backoff up to JOIN_TIMEOUT_S."""
    deadline = time.monotonic() + JOIN_TIMEOUT_S
    gap = POLL_INITIAL_S
    saw_joining = False
    while time.monotonic() < deadline:
        state = node_state(observer, new.address)
        log.info("Join state of %s: %s", new.name, state or "not-yet-visible")
        if state == "UJ":
            saw_joining = True
        elif state == "UN":
            if not saw_joining:
                log.warning("%s is UN but was never seen UJ — verify it did not skip "
                            "bootstrap as a seed before trusting it.", new.name)
            return True
        elif state in {"DN", "DL"}:
            log.error("%s went down during bootstrap (%s) — investigate before retrying.",
                      new.name, state)
            return False
        time.sleep(gap)
        gap = min(gap * 2, POLL_MAX_S)
    log.error("Timed out after %ss waiting for %s to reach UN.", JOIN_TIMEOUT_S, new.name)
    return False


def cleanup_peers(new: Host, peers: list[Host]) -> None:
    """Run nodetool cleanup on each pre-existing node, strictly one at a time."""
    for peer in peers:
        if peer.address == new.address:
            continue
        log.info("Running cleanup on %s (serialized)...", peer.name)
        rc, _ = run_nodetool(peer, ["cleanup"], timeout=JOIN_TIMEOUT_S)
        if rc != 0:
            log.error("cleanup failed on %s — stopping so you can investigate.", peer.name)
            return
        log.info("cleanup complete on %s.", peer.name)


def bootstrap(new: Host, peers: list[Host]) -> int:
    observer = peers[0]                       # any existing node can observe the ring
    existing = node_state(observer, new.address)
    if existing == "UN":                      # idempotency guard: already joined
        log.info("%s already UN — running cleanup only.", new.name)
        cleanup_peers(new, peers)
        return 0
    if existing == "UJ":
        log.info("%s already UJ — resuming the wait rather than restarting.", new.name)
    else:
        if not preflight(observer):
            return 1
        start_node(new)

    if not await_un(observer, new):
        return 2
    log.info("%s reached UN. Proceeding to peer cleanup.", new.name)
    cleanup_peers(new, peers)
    log.info("Bootstrap of %s complete.", new.name)
    return 0


if __name__ == "__main__":
    existing_nodes = [
        Host("cass-a", "10.0.1.10", "ops@10.0.1.10"),
        Host("cass-b", "10.0.1.11", "ops@10.0.1.11"),
        Host("cass-c", "10.0.1.12", "ops@10.0.1.12"),
    ]
    joining = Host("cass-d", "10.0.1.13", "ops@10.0.1.13")
    sys.exit(bootstrap(joining, existing_nodes))

The design encodes the three rules that manual joins violate most often. The preflight gate blocks a start whenever any peer is not UN, which is what enforces one-join-at-a-time — if a prior join is still UJ, the gate fails and the script exits. The await_un loop distinguishes a genuine UJUN transition from a suspicious instant UN, warning loudly if the node was never seen joining; for a live view of how far along that transition is, fold in the byte-level progress and ETA from monitoring streaming progress during bootstrap. And cleanup_peers iterates strictly sequentially, so the reclaim never sacrifices more than one replica’s read performance at a time.

To grow a deployment by several nodes, drive this orchestrator once per new node from an outer loop rather than parallelizing it — because preflight requires an all-UN ring, each invocation naturally waits for the previous join to settle before the next can start, which is exactly the serialization Cassandra demands. Keep the existing_nodes list current between runs: a node added in one pass becomes an observer and cleanup target in the next, and where each new node lands on the ring is governed by token allocation and cluster rebalancing. The run_remote transport shown here shells out over SSH for clarity, but in a managed fleet you would swap it for your configuration-management agent or a JMX client so the control host never needs shell access to the data nodes. Whichever transport you use, the guard clauses are what make the script safe to schedule unattended: a transient nodetool timeout returns a non-fatal code and the poll simply retries, while a genuine state regression to DN aborts with a distinct exit code you can alert on.

Verification steps

Run the orchestrator and confirm each phase from an independent shell. During the join, the observer should report UJ:

nodetool status | grep 10.0.1.13     # expect UJ while streaming

After the script logs reached UN, verify ownership rebalanced and streaming drained:

nodetool status                       # new node UN, Owns evened out
nodetool netstats | grep Mode         # expect "Mode: NORMAL"

Confirm the serialized cleanup actually ran on the peers by checking that no stale ranges remain — a peer’s Load should drop after its cleanup completes:

nodetool status | awk '{print $1, $2, $3, $4}'   # Load lower on cleaned peers

Troubleshooting

  • Other bootstrapping/leaving nodes detected, cannot bootstrap while cassandra.consistent.rangemovement is true. The joining node exits at startup because another node was still UJ or UL when it tried to join. Root cause: a concurrent range movement. Fix: the preflight gate is designed to prevent this — if you hit it, a peer changed state after the gate passed; wait for the ring to return to all-UN, then re-run the script, which will resume idempotently.
  • Stuck streaming / StreamException: Stream failed. The node sits in UJ past a reasonable window and await_un eventually times out; system.log on the joining node shows a StreamException. Root cause: a source node dropped a connection or ran out of I/O headroom mid-transfer. Fix: run nodetool bootstrap resume on the joining node to retry only the incomplete ranges, then re-run the orchestrator — it will find the node still UJ and resume the wait rather than restarting the process. If resume fails repeatedly, wipe the data directory and rejoin from scratch.
  • Schema disagreement blocks the join. preflight fails with “Schema not converged”, or the node logs JOINING: waiting for schema information to complete and never advances. Root cause: an in-flight DDL change left nodes on different schema versions. Fix: resolve the disagreement first — identify the divergent node with nodetool describecluster, restart it if it is stuck, and wait for a single schema UUID before re-running the orchestrator.