Newer
Older
openstack-caracal-dc-dc / scripts / dc-dc-ceph-disk-budget.sh
#!/usr/bin/env bash
# scripts/dc-dc-ceph-disk-budget.sh --total-disk <SIZE> --dc1-nodes <N>
#   --dc1-per-node-osd <SIZE> --dc2-nodes <N> --dc2-per-node-osd <SIZE>
#   --backup-overhead-fraction <FRACTION>
#
# Phase-0 gate calculator (tooling gap register item #7): turns
# buildout-design Section 3 / D-101's Ceph size=3-vs-size=2 disk-budget
# prose into a runnable check, per `runbooks/dc-dc-phase0-vcloud-prep.md`
# Step 3. Mutates NOTHING -- read-only arithmetic against operator-supplied,
# MEASURED inputs; safe to re-run any number of times.
#
# Arithmetic quoted verbatim (docs/dc-dc-buildout-design.md Section 3,
# "Host and resource plan (Phase-0 gates)"):
#   "the Phase-0 disk math must account for: DC1 Ceph (size=3) + DC2 Ceph
#   (size=3) + per-DC radosgw backup pools (themselves replicated within
#   each cluster, and duplicated cross-DC by multisite) + Glance rbd-mirror
#   targets + images + per-DC mirrors. If size=3 across both clusters plus
#   the backup/mirror footprint exceeds [the measured total disk], fall back
#   to size=2 as an EXPLICIT, documented test-only deviation."
#
# Model: this script takes the MEASURED (or firmly planned) per-node OSD
# raw-disk footprint for each DC's cluster -- i.e. what that cluster's own
# replication factor already consumes on each host, exactly as `df`/`ceph
# osd df` would report it once the cluster exists -- multiplies by node
# count to get each DC's Ceph total, sums both DCs, then adds the
# backup/mirror/image overhead as an operator-supplied FRACTION of that
# combined Ceph total (radosgw backup pools + cross-DC multisite
# duplication + Glance rbd-mirror targets + images + per-DC artifact
# mirrors, per the quoted passage). That fraction is NOT specified anywhere
# in this repo as a hard number (checked docs/design-decisions.md and
# docs/dc-dc-buildout-design.md) -- so --backup-overhead-fraction has NO
# DEFAULT and the script refuses to run without it. It must be estimated
# from real Ceph/radosgw-admin usage measurements once DC1's cluster exists
# and reports actual pool sizes (Section 3: "the reserve split ... is set in
# Phase 0 from the measured DC0 per-node OSD footprint").
#
# This script does NOT compute a size=2 disk requirement number: the exact
# per-node footprint under size=2 depends on how the operator would actually
# reconfigure the cluster (a distinct measurement this repo does not yet
# have), and inventing a scaling factor here would be exactly the kind of
# unmeasured arithmetic this repo's discipline forbids. If size=3 does not
# fit, the verdict says so and names size=2 as the ONLY documented fallback
# (D-101 / Section 3) -- and states plainly that adopting it is a decision
# the OPERATOR must explicitly log (its own decision note / changelog
# entry), never something this script silently applies.
#
# Size format: an integer number of bytes, or an integer with a binary
# suffix Ki/Mi/Gi/Ti or K/M/G/T (both forms treated as 1024-based, matching
# `df -h` binary units) -- e.g. 10T, 10Ti, and 10995116277760 are equivalent.
# No decimal suffixes (no SI/1000-based parsing) to avoid a silent unit
# mismatch against `df`/`lsblk` output.
#
# Usage:
#   dc-dc-ceph-disk-budget.sh --total-disk <SIZE> \
#     --dc1-nodes <N> --dc1-per-node-osd <SIZE> \
#     --dc2-nodes <N> --dc2-per-node-osd <SIZE> \
#     --backup-overhead-fraction <FRACTION>
#
#   --total-disk SIZE             REQUIRED. Measured total disk on the
#                                  vcloud host (Step 2's `df`/`lsblk`
#                                  output). No default.
#   --dc1-nodes N                 REQUIRED. Measured/planned Ceph OSD node
#                                  count, DC1. No default.
#   --dc1-per-node-osd SIZE       REQUIRED. Measured/planned per-node raw
#                                  OSD disk footprint, DC1 (already
#                                  reflecting DC1's own size=3 replication
#                                  as it would actually consume disk). No
#                                  default.
#   --dc2-nodes N                 REQUIRED. Same, DC2. No default.
#   --dc2-per-node-osd SIZE       REQUIRED. Same, DC2. No default.
#   --backup-overhead-fraction F  REQUIRED. Decimal fraction (e.g. 0.4 for
#                                 40%) of the combined DC1+DC2 Ceph total
#                                 representing radosgw backup pools +
#                                 cross-DC multisite duplication + Glance
#                                 rbd-mirror targets + images + per-DC
#                                 mirrors. NO DEFAULT -- must come from real
#                                 measurement once DC1 exists; this script
#                                 refuses to run without it explicitly
#                                 passed.
#   -h|--help                    this help.
#
# Exit: 0 PASS (size=3 + overhead fits within measured total disk) | 1
# FAIL/HOLD (does not fit -- size=2 fallback must be explicitly logged by
# the operator, not silently applied) | 2 usage error (missing/bad
# argument). ASCII + LF.
set -uo pipefail
shopt -s inherit_errexit 2>/dev/null || true

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

usage() { sed -n '2,85p' "$HERE/$(basename "${BASH_SOURCE[0]}")" | sed 's/^# \{0,1\}//'; }

TOTAL_DISK=""; DC1_NODES=""; DC1_PER_NODE=""; DC2_NODES=""; DC2_PER_NODE=""; OVERHEAD_FRAC=""

while [ $# -gt 0 ]; do
  case "$1" in
    --total-disk) TOTAL_DISK="${2:-}"; shift 2 ;;
    --total-disk=*) TOTAL_DISK="${1#*=}"; shift ;;
    --dc1-nodes) DC1_NODES="${2:-}"; shift 2 ;;
    --dc1-nodes=*) DC1_NODES="${1#*=}"; shift ;;
    --dc1-per-node-osd) DC1_PER_NODE="${2:-}"; shift 2 ;;
    --dc1-per-node-osd=*) DC1_PER_NODE="${1#*=}"; shift ;;
    --dc2-nodes) DC2_NODES="${2:-}"; shift 2 ;;
    --dc2-nodes=*) DC2_NODES="${1#*=}"; shift ;;
    --dc2-per-node-osd) DC2_PER_NODE="${2:-}"; shift 2 ;;
    --dc2-per-node-osd=*) DC2_PER_NODE="${1#*=}"; shift ;;
    --backup-overhead-fraction) OVERHEAD_FRAC="${2:-}"; shift 2 ;;
    --backup-overhead-fraction=*) OVERHEAD_FRAC="${1#*=}"; shift ;;
    -h|--help) usage; exit 0 ;;
    *) echo "FAIL: unknown argument: $1" >&2; echo "      run with --help for usage" >&2; exit 2 ;;
  esac
done

# --- validation: every input REQUIRED, no defaults (hard rule 2) ------------
missing=0
for pair in "--total-disk:$TOTAL_DISK" "--dc1-nodes:$DC1_NODES" \
            "--dc1-per-node-osd:$DC1_PER_NODE" "--dc2-nodes:$DC2_NODES" \
            "--dc2-per-node-osd:$DC2_PER_NODE" \
            "--backup-overhead-fraction:$OVERHEAD_FRAC"; do
  name="${pair%%:*}"; val="${pair#*:}"
  if [ -z "$val" ]; then
    echo "FAIL: $name is REQUIRED (no default) -- see --help" >&2
    missing=1
  fi
done
[ "$missing" -eq 1 ] && exit 2

is_pos_int() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]; }
if ! is_pos_int "$DC1_NODES"; then echo "FAIL: --dc1-nodes must be a positive integer; got '$DC1_NODES'" >&2; exit 2; fi
if ! is_pos_int "$DC2_NODES"; then echo "FAIL: --dc2-nodes must be a positive integer; got '$DC2_NODES'" >&2; exit 2; fi

# fraction: decimal >= 0 (e.g. 0, 0.4, 1, 1.25)
if ! [[ "$OVERHEAD_FRAC" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
  echo "FAIL: --backup-overhead-fraction must be a non-negative decimal (e.g. 0.4); got '$OVERHEAD_FRAC'" >&2
  exit 2
fi

# --- size parsing: integer bytes, or Ki/Mi/Gi/Ti / K/M/G/T (1024-based) -----
parse_size() { # <label> <value> -- echoes bytes or fails loud (exit 2)
  local label="$1" v="$2" num unit mult
  if [[ "$v" =~ ^([0-9]+)$ ]]; then
    printf '%s\n' "$v"; return 0
  fi
  if [[ "$v" =~ ^([0-9]+)(Ki|Mi|Gi|Ti|K|M|G|T)$ ]]; then
    num="${BASH_REMATCH[1]}"; unit="${BASH_REMATCH[2]}"
    case "$unit" in
      K|Ki) mult=1024 ;;
      M|Mi) mult=$((1024*1024)) ;;
      G|Gi) mult=$((1024*1024*1024)) ;;
      T|Ti) mult=$((1024*1024*1024*1024)) ;;
    esac
    printf '%s\n' "$((num * mult))"; return 0
  fi
  echo "FAIL: $label must be an integer byte count or Ki/Mi/Gi/Ti/K/M/G/T (1024-based); got '$v'" >&2
  return 1
}

TOTAL_DISK_B="$(parse_size "--total-disk" "$TOTAL_DISK")" || exit 2
DC1_PER_NODE_B="$(parse_size "--dc1-per-node-osd" "$DC1_PER_NODE")" || exit 2
DC2_PER_NODE_B="$(parse_size "--dc2-per-node-osd" "$DC2_PER_NODE")" || exit 2

# --- human-readable TiB rendering (2 decimal places, integer bash math) -----
to_tib_str() { # <bytes> -- prints "X.XX" TiB using integer arithmetic only
  local b="$1" tib_scaled
  tib_scaled=$(( (b * 100) / (1024*1024*1024*1024) ))
  printf '%d.%02d' "$((tib_scaled / 100))" "$((tib_scaled % 100))"
}

DC1_TOTAL_B=$((DC1_NODES * DC1_PER_NODE_B))
DC2_TOTAL_B=$((DC2_NODES * DC2_PER_NODE_B))
CEPH_TOTAL_B=$((DC1_TOTAL_B + DC2_TOTAL_B))

# overhead = ceph_total * fraction, computed in fixed-point (avoid bash's
# integer-only arithmetic truncating a decimal fraction outright): scale the
# fraction by 10000 (4 decimal digits of precision) as an integer, multiply,
# then divide back down.
FRAC_SCALED=$(awk -v f="$OVERHEAD_FRAC" 'BEGIN { printf "%d", (f * 10000) + 0.5 }')
OVERHEAD_B=$(( (CEPH_TOTAL_B * FRAC_SCALED) / 10000 ))
REQUIRED_B=$((CEPH_TOTAL_B + OVERHEAD_B))

echo "=== dc-dc-ceph-disk-budget: Section 3 / D-101 Ceph disk-budget gate ==="
echo "  DC1: $DC1_NODES node(s) x $DC1_PER_NODE per-node OSD footprint = $(to_tib_str "$DC1_TOTAL_B") TiB"
echo "  DC2: $DC2_NODES node(s) x $DC2_PER_NODE per-node OSD footprint = $(to_tib_str "$DC2_TOTAL_B") TiB"
echo "  Combined DC1+DC2 Ceph (size=3, as measured/planned): $(to_tib_str "$CEPH_TOTAL_B") TiB"
echo "  Backup/mirror/image overhead (--backup-overhead-fraction $OVERHEAD_FRAC of combined Ceph): $(to_tib_str "$OVERHEAD_B") TiB"
echo "  Required total (size=3 + overhead): $(to_tib_str "$REQUIRED_B") TiB"
echo "  Measured total disk (--total-disk): $(to_tib_str "$TOTAL_DISK_B") TiB"
echo

VERDICT=""
SUMMARY=""
if [ "$REQUIRED_B" -le "$TOTAL_DISK_B" ]; then
  MARGIN_B=$((TOTAL_DISK_B - REQUIRED_B))
  echo "  size=3 (default per the 2026-07-09 operator ruling, D-101/Section 3) FITS."
  echo "  Margin: $(to_tib_str "$MARGIN_B") TiB."
  VERDICT="PASS"
  SUMMARY="CEPH-BUDGET: total=$(to_tib_str "$TOTAL_DISK_B")TiB required(size3+overhead)=$(to_tib_str "$REQUIRED_B")TiB verdict=PASS margin=$(to_tib_str "$MARGIN_B")TiB"
else
  SHORTFALL_B=$((REQUIRED_B - TOTAL_DISK_B))
  echo "  size=3 does NOT fit within the measured total disk."
  echo "  Shortfall: $(to_tib_str "$SHORTFALL_B") TiB."
  echo
  echo "  Per D-101 / buildout-design Section 3: size=2 is the ONLY documented"
  echo "  fallback -- but this script does NOT apply it. Falling back to"
  echo "  size=2 is CONFIRMED as adopted policy only as an EXPLICIT, logged"
  echo "  operator decision (its own decision note / changelog entry), never"
  echo "  a silent or automatic choice. Log that decision before proceeding"
  echo "  if size=2 is chosen; otherwise increase measured disk or lower the"
  echo "  overhead footprint before re-running this gate."
  VERDICT="FAIL"
  SUMMARY="CEPH-BUDGET: total=$(to_tib_str "$TOTAL_DISK_B")TiB required(size3+overhead)=$(to_tib_str "$REQUIRED_B")TiB verdict=FAIL shortfall=$(to_tib_str "$SHORTFALL_B")TiB -- size=2 fallback (D-101/Section 3) requires an explicit logged operator decision"
fi

echo
echo "VERDICT: $VERDICT"
echo "$SUMMARY"

[ "$VERDICT" = "PASS" ] && exit 0 || exit 1