#!/bin/bash # Rehearse the k3s single -> dual-stack conversion on the 3-server etcd cluster, # the way production Phase 4 will do it: edit each server's config.yaml (rendered # by the PRODUCTION generator) to add the second address family, restart ONE # server at a time, and watch what happens in between. # # Answers the questions a single-node lab cannot: # - does quorum survive a rolling config.yaml change across 3 etcd servers? # - what does a MIXED control plane do (one server dual, two still v4-only)? # - does ServiceCIDR pick up the v6 range on the FIRST server's restart, or # only once all three agree? # # The node-ip v6 must exist on the box before k3s reads it, so each server first # gets a ULA on its interface (fd00:2::3x), mirroring how production nodes get a # DHCPv6 address before k3s starts. # # ./labsim-dualstack-convert.sh baseline snapshot the v4-only starting state # ./labsim-dualstack-convert.sh convert roll the conversion, snapshotting each step # ./labsim-dualstack-convert.sh snapshot print current SC / podCIDRs / quorum set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" NET="${NET:-172.31.2}"; FIRST_OCTET="${FIRST_OCTET:-31}"; SERVERS="${SERVERS:-3}" TOKEN="${TOKEN:-labsim-etcd-token}" RENDER="${RENDER:-$SCRIPT_DIR/../bastion/src/modules/dist/modules/k3s/bin/render-config.js}" EVID="${EVID:-$SCRIPT_DIR/dualstack-evidence}" # Dual-stack target ranges. ULA/v4 -- the mechanism is what's under test, not the # addresses; using ULA keeps sim traffic out of the real /48. V4_CLUSTER="10.42.0.0/16"; V6_CLUSTER="fd00:42::/56" V4_SERVICE="10.43.0.0/16"; V6_SERVICE="fd00:43::/112" # /112: apiserver caps v6 service ranges V6_NODE_PREFIX="fd00:2" # node-ip v6: fd00:2::3x SSH=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=8 -o BatchMode=yes) node_ip() { echo "${NET}.$((FIRST_OCTET + $1 - 1))"; } node_v6() { echo "${V6_NODE_PREFIX}::$((FIRST_OCTET + $1 - 1))"; } node_name() { echo "labsim-etcd$1"; } s() { local n="$1"; shift; timeout "${TMO:-25}" ssh "${SSH[@]}" "debian@$(node_ip "$n")" "$@" 2>/dev/null; } k() { s 1 "sudo k3s kubectl $*"; } log() { printf '\033[36m==>\033[0m %s\n' "$*"; } snapshot() { # custom-columns, not jsonpath: the jsonpath range/quotes get mangled through # ssh -> sudo -> kubectl and came back empty. echo " servicecidr :" s 1 'sudo k3s kubectl get servicecidr -o custom-columns=NAME:.metadata.name,CIDRS:.spec.cidrs --no-headers' 2>/dev/null | sed 's/^/ /' echo " node podCIDRs:" s 1 'sudo k3s kubectl get nodes -o custom-columns=NAME:.metadata.name,PODCIDRS:.spec.podCIDRs --no-headers' 2>/dev/null | sed 's/^/ /' local ready; ready="$(k get nodes --no-headers 2>/dev/null | wc -l)" echo " nodes registered: $ready ; apiserver: $(k get --raw /readyz >/dev/null 2>&1 && echo ok || echo DOWN)" } # Re-render node n's config.yaml WITH the dual families and write it back. convert_node() { local n="$1" local ip; ip="$(node_ip "$n")"; local v6; v6="$(node_v6 "$n")" log "server $n ($ip): add $v6, re-render dual config.yaml, restart k3s" # 1. node-ip v6 must exist before k3s reads it. s "$n" "sudo ip -6 addr add ${v6}/64 dev enp1s0 2>/dev/null; ip -6 -br addr show enp1s0 | grep -o '${v6}/64'" | sed 's/^/ addr: /' # 2. render the dual config from the PRODUCTION generator, preserving this # node's role (node 1 cluster-init, else joining server). local extra="" [ "$n" -ne 1 ] && extra="K3S_SERVER_URL=https://$(node_ip 1):6443 K3S_TOKEN=$TOKEN" local cfg cfg="$(env ROLE=infra HOSTNAME="$(node_name "$n")" IP="$ip" TLS_SANS="$ip" \ IPV6="$v6" CLUSTER_CIDR="${V4_CLUSTER},${V6_CLUSTER}" SERVICE_CIDR="${V4_SERVICE},${V6_SERVICE}" \ $extra node "$RENDER")" # sanity: the render must actually carry both families or the restart is pointless echo "$cfg" | grep -q "$V6_CLUSTER" || { echo " RENDER MISSING v6 -- aborting"; return 1; } # 3. write it back and restart k3s on this one server. printf '%s\n' "$cfg" | s "$n" "sudo tee /etc/rancher/k3s/config.yaml >/dev/null && sudo systemctl restart k3s" # 4. wait for THIS server's k3s to come back and the apiserver to answer. local i for i in $(seq 1 30); do [ "$(s "$n" 'sudo systemctl is-active k3s' 2>/dev/null)" = active ] && \ s "$n" 'sudo k3s kubectl get --raw /readyz >/dev/null 2>&1' && break sleep 10 done echo " server $n k3s=$(s "$n" 'systemctl is-active k3s') restarts=$(s "$n" 'systemctl show k3s -p NRestarts --value')" } cmd_baseline() { mkdir -p "$EVID" { echo "=== BASELINE (v4-only) $(date -u +%FT%TZ) ==="; snapshot; } | tee "$EVID/convert-baseline.txt" } cmd_snapshot() { snapshot; } cmd_convert() { mkdir -p "$EVID" local out="$EVID/convert-run.txt" { echo "=== 3-SERVER DUAL-STACK CONVERSION $(date -u +%FT%TZ) ===" echo "--- before ---"; snapshot local n for n in $(seq 1 "$SERVERS"); do echo; echo "### converting server $n of $SERVERS ###" convert_node "$n" || { echo "convert_node $n failed"; break; } echo "--- state after server $n (MIXED until n=$SERVERS) ---" snapshot done echo; echo "--- FINAL ---"; snapshot } 2>&1 | tee "$out" log "evidence -> $out" } case "${1:-snapshot}" in baseline) cmd_baseline ;; convert) cmd_convert ;; snapshot) cmd_snapshot ;; *) echo "usage: $0 {baseline|convert|snapshot}" >&2; exit 2 ;; esac