#!/usr/bin/env python3
"""Whole-host capacity budget for the VR1 nested deployment (D-121 / R-3 / Model B).
Replaces the lost, never-committed `scratchpad/optc-calc.py` (review R3-F06) with a
committed, harnessed, read-only calculator -- the sibling of `dc-dc-ceph-disk-budget.sh`.
It SUMS the RAM / vCPU / disk allocation of every VR1 layer (both DCs' node fleets, the
site containment VMs, Office1, the edges) and compares it to the MEASURED host budget,
reporting the binding resource and a FIT / NO-FIT verdict.
Mutates nothing. Every number is an operator-supplied or ruled input -- no live host is
queried and nothing is inferred. Defaults encode the ruled layout (Option C = 3 control +
2 compute + 4 storage/DC, D-121 R-3) under D-123 **Model B** (the DC node fleet lives INSIDE
the site containment VM), but every value is overridable so the tool outlives the ruling.
RAM is the binding resource in a nested sim (vCPU overcommits; disk is thin-provisioned),
so RAM fit is the gate; vCPU/disk are reported as ratios, not hard ceilings.
Usage:
python3 dc-dc-whole-host-budget.py [--model A|B] [--dcs N]
[--control N,VCPU,MEM_GIB,DISK_GIB] [--compute ...] [--storage ...]
[--containment-overhead-mem-gib G] [--containment-overhead-vcpu V]
[--office1 VCPU,MEM_GIB,DISK_GIB] [--edge-count N] [--edge MEM_GIB,VCPU]
[--host VCPU,MEM_GIB,DISK_GIB]
Exit: 0 = FIT (RAM within budget), 1 = NO-FIT (RAM over budget), 2 = usage error.
"""
import argparse
import sys
def triple(s, n=3):
parts = s.split(",")
if len(parts) != n:
raise argparse.ArgumentTypeError(f"expected {n} comma-separated numbers, got '{s}'")
return [float(p) for p in parts]
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
# Node roles per DC: count, vCPU, mem GiB, disk GiB (ruled Option C / R-3 defaults)
ap.add_argument("--control", type=lambda s: triple(s, 4), default=[3, 16, 64, 150],
help="control nodes/DC: count,vcpu,mem_gib,disk_gib (default 3,16,64,150)")
ap.add_argument("--compute", type=lambda s: triple(s, 4), default=[2, 12, 48, 100],
help="compute nodes/DC: count,vcpu,mem_gib,disk_gib (default 2,12,48,100)")
ap.add_argument("--storage", type=lambda s: triple(s, 4), default=[4, 8, 24, 550],
help="storage nodes/DC: count,vcpu,mem_gib,disk_gib (default 4,8,24,550 -- R-3)")
ap.add_argument("--dcs", type=int, default=2, help="number of DCs (default 2)")
ap.add_argument("--model", choices=["A", "B"], default="B",
help="D-123 model: A=nodes vcloud-level (+ small rack); B=nodes inside containment VM (default B)")
# Containment VM overhead (Model B: the VM's own OS + MAAS rack + LXD, ON TOP of the node RAM it holds).
ap.add_argument("--containment-overhead-mem-gib", type=float, default=16.0,
help="per-DC containment-VM overhead RAM GiB, Model B (default 16)")
ap.add_argument("--containment-overhead-vcpu", type=float, default=4.0,
help="per-DC containment-VM overhead vCPU, Model B (default 4)")
# Model A rack headend (small, holds no nodes) -- D-124 original 4/8/80.
ap.add_argument("--rack", type=lambda s: triple(s, 3), default=[4, 8, 80],
help="Model A rack headend/DC: vcpu,mem_gib,disk_gib (default 4,8,80)")
# Office1 (voffice1) + edges.
ap.add_argument("--office1", type=lambda s: triple(s, 3), default=[16, 32, 600],
help="Office1 voffice1: vcpu,mem_gib,disk_gib (default 16,32,600)")
ap.add_argument("--edge-count", type=int, default=3, help="OPNsense edges total (office1 + per DC; default 3)")
ap.add_argument("--edge", type=lambda s: triple(s, 2), default=[2, 2],
help="per-edge: mem_gib,vcpu (default 2,2)")
# Measured host budget.
ap.add_argument("--host", type=lambda s: triple(s, 3), default=[256, 1024, 10240],
help="MEASURED host budget: vcpu,mem_gib,disk_gib (default 256,1024,10240)")
args = ap.parse_args()
def role_sum(role):
cnt, vcpu, mem, disk = role
return cnt * vcpu, cnt * mem, cnt * disk
# Per-DC node fleet.
dc_vcpu = dc_mem = dc_disk = 0.0
for role in (args.control, args.compute, args.storage):
v, m, d = role_sum(role)
dc_vcpu += v; dc_mem += m; dc_disk += d
lines = []
lines.append(f"Per-DC node fleet: {dc_vcpu:.0f} vCPU / {dc_mem:.0f} GiB / {dc_disk:.0f} GiB")
tot_vcpu = tot_mem = tot_disk = 0.0
# Both DCs' nodes.
tot_vcpu += args.dcs * dc_vcpu
tot_mem += args.dcs * dc_mem
tot_disk += args.dcs * dc_disk
if args.model == "B":
# Nodes live INSIDE the containment VM; add per-DC containment overhead on top.
tot_vcpu += args.dcs * args.containment_overhead_vcpu
tot_mem += args.dcs * args.containment_overhead_mem_gib
per_dc_vm = dc_mem + args.containment_overhead_mem_gib
lines.append(f"Model B: each containment VM (vvr1-dcN) holds one DC's fleet -> ~{per_dc_vm:.0f} GiB RAM each")
lines.append(f" + containment overhead {args.dcs} x ({args.containment_overhead_vcpu:.0f} vCPU / "
f"{args.containment_overhead_mem_gib:.0f} GiB)")
else:
# Model A: nodes at vcloud level, plus a small rack headend per DC.
rv, rm, rd = args.rack
tot_vcpu += args.dcs * rv; tot_mem += args.dcs * rm; tot_disk += args.dcs * rd
lines.append(f"Model A: nodes vcloud-level + a {rv:.0f}/{rm:.0f}/{rd:.0f} rack headend x{args.dcs}")
# Office1 + edges (both models).
ov, om, od = args.office1
tot_vcpu += ov; tot_mem += om; tot_disk += od
em, ev = args.edge
tot_vcpu += args.edge_count * ev
tot_mem += args.edge_count * em
lines.append(f"Office1 voffice1: {ov:.0f}/{om:.0f}/{od:.0f}; edges: {args.edge_count} x ({ev:.0f} vCPU / {em:.0f} GiB)")
hv, hm, hd = args.host
print("=== dc-dc-whole-host-budget: VR1 nested capacity (D-121 / R-3 / Model {}) ===".format(args.model))
for ln in lines:
print(" " + ln)
print()
print(f" TOTAL allocation : {tot_vcpu:.0f} vCPU / {tot_mem:.0f} GiB / {tot_disk:.0f} GiB")
print(f" MEASURED host : {hv:.0f} vCPU / {hm:.0f} GiB / {hd:.0f} GiB")
print(f" RAM : {tot_mem:.0f}/{hm:.0f} GiB = {100*tot_mem/hm:.0f}% <- BINDING resource")
print(f" vCPU : {tot_vcpu:.0f}/{hv:.0f} = {100*tot_vcpu/hv:.0f}% (overcommits in a sim; not a hard ceiling)")
print(f" disk : {tot_disk:.0f}/{hd:.0f} GiB = {100*tot_disk/hd:.0f}% (thin-provisioned)")
print()
ram_fit = tot_mem <= hm
if ram_fit:
print(f"VERDICT: FIT -- RAM within budget ({hm - tot_mem:.0f} GiB headroom).")
print(f"WHOLE-HOST: model={args.model} ram={tot_mem:.0f}/{hm:.0f}GiB verdict=FIT "
f"headroom={hm - tot_mem:.0f}GiB vcpu={tot_vcpu:.0f}/{hv:.0f} disk={tot_disk:.0f}/{hd:.0f}GiB")
return 0
else:
print(f"VERDICT: NO-FIT -- RAM OVER budget by {tot_mem - hm:.0f} GiB. Reduce per-node RAM or node count.")
print(f"WHOLE-HOST: model={args.model} ram={tot_mem:.0f}/{hm:.0f}GiB verdict=NO-FIT over={tot_mem - hm:.0f}GiB")
return 1
if __name__ == "__main__":
sys.exit(main())