Coverage for cli / capacity / models.py: 100.00%

128 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Data classes for capacity checking. 

2 

3Instance-type characteristics are resolved live from 

4``ec2:DescribeInstanceTypes`` — see :func:`instance_type_info_from_ec2`. This 

5module intentionally holds no checked-in instance specification table: one used 

6to live here (``GPU_INSTANCE_SPECS``, 25 GPU types) and short-circuited the API, 

7which meant a new accelerator family was invisible until someone remembered to 

8hand-edit it, and a wrong number propagated silently into NodePool sizing and 

9capacity scores. One API call per lookup is cheaper than that failure mode. 

10""" 

11 

12from __future__ import annotations 

13 

14from dataclasses import dataclass, field 

15from typing import Any 

16 

17 

18class CapacityCheckError(Exception): 

19 """A primary capacity availability check failed at the AWS API level. 

20 

21 Raised when an availability lookup (e.g. DescribeInstanceTypeOfferings) 

22 fails due to throttling, expired/invalid credentials, denied permissions, 

23 or a region that isn't opted in. This is distinct from a *successful* lookup 

24 that reports an instance type as genuinely not offered — masking such 

25 failures as "unavailable" hides real, actionable errors from the caller. 

26 """ 

27 

28 

29@dataclass 

30class InstanceTypeInfo: 

31 """Compute characteristics of an EC2 instance type, as EC2 reports them. 

32 

33 Every field is resolved live from ``ec2:DescribeInstanceTypes``. There is 

34 deliberately no checked-in specification table behind this: a hand-maintained 

35 catalog silently goes stale, and a stale accelerator count feeds directly 

36 into NodePool sizing and capacity scoring. Asking EC2 costs one API call and 

37 is always right. 

38 

39 The first seven fields keep their historical names and order — the capacity 

40 heuristics and the recommender read them positionally — and everything added 

41 since carries a default, so a partial record from an older API shape still 

42 constructs. 

43 

44 Memory conventions, which are easy to get wrong: 

45 

46 * ``memory_gib`` is host RAM. 

47 * ``gpu_memory_gib`` is the **total** across all accelerators, taken from 

48 ``GpuInfo.TotalGpuMemoryInMiB``. It is not per-device: a heterogeneous 

49 ``Gpus[]`` list would make ``count x per_device`` wrong, and the 

50 on-demand availability heuristics read this field as a total. 

51 * ``gpu_devices`` carries the per-model breakdown when you need it. 

52 """ 

53 

54 instance_type: str 

55 vcpus: int 

56 memory_gib: float 

57 gpu_count: int = 0 

58 gpu_type: str | None = None 

59 gpu_memory_gib: float = 0 

60 architecture: str = "x86_64" 

61 

62 # --- Region the description came from ------------------------------- 

63 # DescribeInstanceTypes is region-scoped: a type absent here may exist 

64 # elsewhere, so the answer is only meaningful alongside its region. 

65 region: str | None = None 

66 

67 # --- Processor ------------------------------------------------------ 

68 cores: int | None = None 

69 threads_per_core: int | None = None 

70 architectures: list[str] = field(default_factory=list) 

71 sustained_clock_speed_ghz: float | None = None 

72 processor_manufacturer: str | None = None 

73 current_generation: bool | None = None 

74 bare_metal: bool | None = None 

75 hypervisor: str | None = None 

76 burstable: bool | None = None 

77 free_tier_eligible: bool | None = None 

78 

79 # --- Accelerators --------------------------------------------------- 

80 #: Per-model GPU breakdown: name, manufacturer, count, memory_gib. 

81 gpu_devices: list[dict[str, Any]] = field(default_factory=list) 

82 gpu_manufacturer: str | None = None 

83 #: AWS Neuron (Trainium / Inferentia2) devices, same shape as gpu_devices. 

84 neuron_devices: list[dict[str, Any]] = field(default_factory=list) 

85 neuron_count: int = 0 

86 neuron_memory_gib: float = 0 

87 #: First-generation Inferentia, reported separately by EC2. 

88 inference_accelerators: list[dict[str, Any]] = field(default_factory=list) 

89 inference_accelerator_count: int = 0 

90 media_accelerators: list[dict[str, Any]] = field(default_factory=list) 

91 fpgas: list[dict[str, Any]] = field(default_factory=list) 

92 

93 # --- Network -------------------------------------------------------- 

94 efa_supported: bool | None = None 

95 efa_max_interfaces: int | None = None 

96 network_performance: str | None = None 

97 maximum_network_interfaces: int | None = None 

98 maximum_network_cards: int | None = None 

99 ipv6_supported: bool | None = None 

100 ena_support: str | None = None 

101 encryption_in_transit_supported: bool | None = None 

102 

103 # --- Storage -------------------------------------------------------- 

104 instance_storage_supported: bool | None = None 

105 instance_storage_total_gb: int | None = None 

106 instance_storage_disks: list[dict[str, Any]] = field(default_factory=list) 

107 instance_storage_nvme: str | None = None 

108 ebs_optimized_support: str | None = None 

109 ebs_encryption_support: str | None = None 

110 ebs_nvme_support: str | None = None 

111 ebs_baseline_iops: int | None = None 

112 ebs_maximum_iops: int | None = None 

113 ebs_baseline_throughput_mbps: float | None = None 

114 ebs_maximum_throughput_mbps: float | None = None 

115 

116 # --- Purchasing and placement --------------------------------------- 

117 supported_usage_classes: list[str] = field(default_factory=list) 

118 supported_placement_strategies: list[str] = field(default_factory=list) 

119 dedicated_hosts_supported: bool | None = None 

120 

121 # --- Platform capabilities ------------------------------------------ 

122 supported_root_device_types: list[str] = field(default_factory=list) 

123 supported_virtualization_types: list[str] = field(default_factory=list) 

124 supported_boot_modes: list[str] = field(default_factory=list) 

125 hibernation_supported: bool | None = None 

126 auto_recovery_supported: bool | None = None 

127 nitro_enclaves_support: str | None = None 

128 nitro_tpm_support: str | None = None 

129 

130 @property 

131 def is_gpu(self) -> bool: 

132 return self.gpu_count > 0 

133 

134 @property 

135 def is_accelerated(self) -> bool: 

136 """True when the type carries any accelerator, not just an NVIDIA GPU. 

137 

138 ``is_gpu`` deliberately stays GPU-only because the capacity heuristics 

139 use it to reason about GPU scarcity specifically; a Trainium node is a 

140 different supply pool. 

141 """ 

142 return bool( 

143 self.gpu_count 

144 or self.neuron_count 

145 or self.inference_accelerator_count 

146 or self.media_accelerators 

147 or self.fpgas 

148 ) 

149 

150 @property 

151 def spot_supported(self) -> bool: 

152 return "spot" in self.supported_usage_classes 

153 

154 @property 

155 def capacity_block_supported(self) -> bool: 

156 return "capacity-block" in self.supported_usage_classes 

157 

158 

159@dataclass 

160class SpotPriceInfo: 

161 """Spot price information for an instance type.""" 

162 

163 instance_type: str 

164 availability_zone: str 

165 current_price: float 

166 avg_price_7d: float 

167 min_price_7d: float 

168 max_price_7d: float 

169 price_stability: float # 0-1, higher is more stable 

170 

171 

172@dataclass 

173class CapacityEstimate: 

174 """Capacity availability estimate.""" 

175 

176 instance_type: str 

177 region: str 

178 availability_zone: str | None 

179 capacity_type: str # "spot" or "on-demand" 

180 availability: str # "high", "medium", "low", "unavailable", "unknown" 

181 confidence: float # 0-1 

182 estimated_wait_time: str | None = None 

183 price_per_hour: float | None = None 

184 recommendation: str = "" 

185 details: dict[str, Any] = field(default_factory=dict) 

186 error: str | None = None # Explicit error string for partial/degraded results 

187 

188 

189def _mib_to_gib(value: Any) -> float | None: 

190 """Convert an EC2 MiB quantity to GiB, or None when absent.""" 

191 if value is None: 

192 return None 

193 try: 

194 return round(float(value) / 1024, 2) 

195 except TypeError, ValueError: 

196 return None 

197 

198 

199def instance_type_info_from_ec2( 

200 record: dict[str, Any], region: str | None = None 

201) -> InstanceTypeInfo: 

202 """Build an :class:`InstanceTypeInfo` from one ``DescribeInstanceTypes`` entry. 

203 

204 A pure function so the mapping can be tested against recorded API shapes 

205 without AWS credentials, and so the CLI and any future caller derive the 

206 same fields from the same payload. 

207 

208 Every read is defensive. EC2 omits whole field groups depending on the 

209 instance family (``GpuInfo`` only on GPU types, ``NeuronInfo`` only on 

210 Trainium/Inferentia2, ``InstanceStorageInfo`` only when local disks exist), 

211 and it adds new groups over time, so a missing key is normal and must not 

212 raise. Lists are read in full rather than indexed at ``[0]``: a family with 

213 two accelerator models or two supported architectures would otherwise be 

214 silently misreported. 

215 """ 

216 vcpu_info = record.get("VCpuInfo") or {} 

217 memory_info = record.get("MemoryInfo") or {} 

218 processor_info = record.get("ProcessorInfo") or {} 

219 gpu_info = record.get("GpuInfo") or {} 

220 neuron_info = record.get("NeuronInfo") or {} 

221 inference_info = record.get("InferenceAcceleratorInfo") or {} 

222 media_info = record.get("MediaAcceleratorInfo") or {} 

223 fpga_info = record.get("FpgaInfo") or {} 

224 network_info = record.get("NetworkInfo") or {} 

225 efa_info = network_info.get("EfaInfo") or {} 

226 storage_info = record.get("InstanceStorageInfo") or {} 

227 ebs_info = record.get("EbsInfo") or {} 

228 ebs_throughput = ebs_info.get("EbsOptimizedInfo") or {} 

229 placement_info = record.get("PlacementGroupInfo") or {} 

230 

231 architectures = list(processor_info.get("SupportedArchitectures") or []) 

232 

233 gpu_devices = [ 

234 { 

235 "name": device.get("Name"), 

236 "manufacturer": device.get("Manufacturer"), 

237 "count": device.get("Count"), 

238 "memory_gib": _mib_to_gib((device.get("MemoryInfo") or {}).get("SizeInMiB")), 

239 } 

240 for device in (gpu_info.get("Gpus") or []) 

241 ] 

242 # Sum across models rather than trusting Gpus[0].Count — a heterogeneous 

243 # list would otherwise undercount the node's real accelerator budget. 

244 gpu_count = sum(int(device.get("count") or 0) for device in gpu_devices) 

245 

246 # Prefer EC2's own total. Fall back to count x per-device across models when 

247 # the field is absent, because reporting 0 GiB for a node that plainly has 

248 # GPUs is worse than a derived figure — the on-demand availability 

249 # heuristics read this as a total and would score the node as CPU-only. 

250 total_gpu_memory_gib = _mib_to_gib(gpu_info.get("TotalGpuMemoryInMiB")) 

251 if total_gpu_memory_gib is None and gpu_devices: 

252 derived = sum( 

253 float(device.get("memory_gib") or 0) * int(device.get("count") or 0) 

254 for device in gpu_devices 

255 ) 

256 total_gpu_memory_gib = round(derived, 2) if derived else None 

257 

258 neuron_devices = [ 

259 { 

260 "name": device.get("Name"), 

261 "count": device.get("Count"), 

262 "core_count": (device.get("CoreInfo") or {}).get("Count"), 

263 "core_version": (device.get("CoreInfo") or {}).get("Version"), 

264 "memory_gib": _mib_to_gib((device.get("MemoryInfo") or {}).get("SizeInMiB")), 

265 } 

266 for device in (neuron_info.get("NeuronDevices") or []) 

267 ] 

268 

269 inference_accelerators = [ 

270 { 

271 "name": device.get("Name"), 

272 "manufacturer": device.get("Manufacturer"), 

273 "count": device.get("Count"), 

274 "memory_gib": _mib_to_gib((device.get("MemoryInfo") or {}).get("SizeInMiB")), 

275 } 

276 for device in (inference_info.get("Accelerators") or []) 

277 ] 

278 

279 return InstanceTypeInfo( 

280 instance_type=str(record.get("InstanceType") or ""), 

281 vcpus=int(vcpu_info.get("DefaultVCpus") or 0), 

282 memory_gib=_mib_to_gib(memory_info.get("SizeInMiB")) or 0.0, 

283 gpu_count=gpu_count, 

284 # Join multiple models so the scalar field stays informative on a 

285 # heterogeneous type instead of naming only the first. 

286 gpu_type=( 

287 "+".join(str(device["name"]) for device in gpu_devices if device.get("name")) or None 

288 ), 

289 # Total, not per-device — see the InstanceTypeInfo docstring. 

290 gpu_memory_gib=total_gpu_memory_gib or 0.0, 

291 architecture=architectures[0] if architectures else "x86_64", 

292 region=region, 

293 cores=vcpu_info.get("DefaultCores"), 

294 threads_per_core=vcpu_info.get("DefaultThreadsPerCore"), 

295 architectures=architectures, 

296 sustained_clock_speed_ghz=processor_info.get("SustainedClockSpeedInGhz"), 

297 processor_manufacturer=processor_info.get("Manufacturer"), 

298 current_generation=record.get("CurrentGeneration"), 

299 bare_metal=record.get("BareMetal"), 

300 hypervisor=record.get("Hypervisor"), 

301 burstable=record.get("BurstablePerformanceSupported"), 

302 free_tier_eligible=record.get("FreeTierEligible"), 

303 gpu_devices=gpu_devices, 

304 gpu_manufacturer=next( 

305 (str(device["manufacturer"]) for device in gpu_devices if device.get("manufacturer")), 

306 None, 

307 ), 

308 neuron_devices=neuron_devices, 

309 neuron_count=sum(int(device.get("count") or 0) for device in neuron_devices), 

310 neuron_memory_gib=_mib_to_gib(neuron_info.get("TotalNeuronDeviceMemoryInMiB")) or 0.0, 

311 inference_accelerators=inference_accelerators, 

312 inference_accelerator_count=sum( 

313 int(device.get("count") or 0) for device in inference_accelerators 

314 ), 

315 media_accelerators=[ 

316 { 

317 "name": device.get("Name"), 

318 "manufacturer": device.get("Manufacturer"), 

319 "count": device.get("Count"), 

320 "memory_gib": _mib_to_gib((device.get("MemoryInfo") or {}).get("SizeInMiB")), 

321 } 

322 for device in (media_info.get("Accelerators") or []) 

323 ], 

324 fpgas=[ 

325 { 

326 "name": device.get("Name"), 

327 "manufacturer": device.get("Manufacturer"), 

328 "count": device.get("Count"), 

329 "memory_gib": _mib_to_gib((device.get("MemoryInfo") or {}).get("SizeInMiB")), 

330 } 

331 for device in (fpga_info.get("Fpgas") or []) 

332 ], 

333 efa_supported=network_info.get("EfaSupported"), 

334 efa_max_interfaces=efa_info.get("MaximumEfaInterfaces"), 

335 network_performance=network_info.get("NetworkPerformance"), 

336 maximum_network_interfaces=network_info.get("MaximumNetworkInterfaces"), 

337 maximum_network_cards=network_info.get("MaximumNetworkCards"), 

338 ipv6_supported=network_info.get("Ipv6Supported"), 

339 ena_support=network_info.get("EnaSupport"), 

340 encryption_in_transit_supported=network_info.get("EncryptionInTransitSupported"), 

341 instance_storage_supported=record.get("InstanceStorageSupported"), 

342 instance_storage_total_gb=storage_info.get("TotalSizeInGB"), 

343 instance_storage_disks=[ 

344 { 

345 "size_gb": disk.get("SizeInGB"), 

346 "count": disk.get("Count"), 

347 "type": disk.get("Type"), 

348 } 

349 for disk in (storage_info.get("Disks") or []) 

350 ], 

351 instance_storage_nvme=storage_info.get("NvmeSupport"), 

352 ebs_optimized_support=ebs_info.get("EbsOptimizedSupport"), 

353 ebs_encryption_support=ebs_info.get("EncryptionSupport"), 

354 ebs_nvme_support=ebs_info.get("NvmeSupport"), 

355 ebs_baseline_iops=ebs_throughput.get("BaselineIops"), 

356 ebs_maximum_iops=ebs_throughput.get("MaximumIops"), 

357 ebs_baseline_throughput_mbps=ebs_throughput.get("BaselineThroughputInMBps"), 

358 ebs_maximum_throughput_mbps=ebs_throughput.get("MaximumThroughputInMBps"), 

359 supported_usage_classes=list(record.get("SupportedUsageClasses") or []), 

360 supported_placement_strategies=list(placement_info.get("SupportedStrategies") or []), 

361 dedicated_hosts_supported=record.get("DedicatedHostsSupported"), 

362 supported_root_device_types=list(record.get("SupportedRootDeviceTypes") or []), 

363 supported_virtualization_types=list(record.get("SupportedVirtualizationTypes") or []), 

364 supported_boot_modes=list(record.get("SupportedBootModes") or []), 

365 hibernation_supported=record.get("HibernationSupported"), 

366 auto_recovery_supported=record.get("AutoRecoverySupported"), 

367 nitro_enclaves_support=record.get("NitroEnclavesSupport"), 

368 nitro_tpm_support=record.get("NitroTpmSupport"), 

369 )