#!/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