Coverage for cli / commands / nodepools_cmd.py: 100.00%

141 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""NodePool commands.""" 

2 

3import sys 

4from collections.abc import Iterator 

5from contextlib import contextmanager 

6from typing import Any 

7 

8import click 

9 

10from ..config import GCOConfig 

11from ..ephemeral_bastion import DEFAULT_TTL_MINUTES 

12from ..output import confirm, get_output_formatter 

13 

14pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

15 

16 

17@click.group() 

18@pass_config 

19def nodepools(config: Any) -> None: 

20 """Manage Karpenter NodePools with ODCR/Capacity Reservation support.""" 

21 

22 

23_CLUSTER_HELP = "EKS cluster name (defaults to <project_name>-<region>)" 

24_OUTPUT_FILE_HELP = "Write the manifest to this file instead of printing it" 

25 

26 

27def _write_or_print_manifest(formatter: Any, manifest: str, output_file: str | None) -> None: 

28 """Print the generated manifest, or write it to ``output_file``, with the apply hint.""" 

29 if output_file: 

30 with open(output_file, "w", encoding="utf-8") as f: 

31 f.write(manifest) 

32 formatter.print_success(f"NodePool manifest written to {output_file}") 

33 formatter.print_info(f"Apply with: kubectl apply -f {output_file}") 

34 else: 

35 # Print the manifest for review 

36 print(manifest) 

37 formatter.print_info("\nTo apply this manifest, save it to a file and run:") 

38 formatter.print_info(" kubectl apply -f <filename>.yaml") 

39 

40 

41@nodepools.command("create-odcr") 

42@click.option("--name", "-n", required=True, help="NodePool name") 

43@click.option("--region", "-r", required=True, help="AWS region") 

44@click.option( 

45 "--capacity-reservation-id", 

46 "-c", 

47 required=True, 

48 help="EC2 Capacity Reservation ID (cr-xxx)", 

49) 

50@click.option( 

51 "--instance-type", 

52 "-i", 

53 multiple=True, 

54 help="Instance types (can specify multiple)", 

55) 

56@click.option("--max-nodes", default=100, help="Maximum nodes in pool") 

57@click.option("--fallback-on-demand", is_flag=True, help="Fall back to on-demand if ODCR exhausted") 

58@click.option( 

59 "--efa", 

60 is_flag=True, 

61 help="Enable EFA support (adds EFA taint and labels for p4d/p5 instances)", 

62) 

63@click.option("--cluster", help=_CLUSTER_HELP) 

64@click.option("--output-file", "-o", help=_OUTPUT_FILE_HELP) 

65@pass_config 

66def create_odcr_nodepool( 

67 config: Any, 

68 name: Any, 

69 region: Any, 

70 capacity_reservation_id: Any, 

71 instance_type: Any, 

72 max_nodes: Any, 

73 fallback_on_demand: Any, 

74 efa: Any, 

75 cluster: Any, 

76 output_file: Any, 

77) -> None: 

78 """Generate a NodePool backed by an On-Demand Capacity Reservation (ODCR). 

79 

80 Prints (or writes) an EKS Auto Mode NodeClass that binds the reservation 

81 through capacityReservationSelectorTerms, using the cluster's own node 

82 role, subnets and security group, plus a NodePool that launches reserved 

83 capacity through it. Apply it with kubectl; pods select the pool with 

84 nodeSelector karpenter.sh/nodepool: <name>. 

85 

86 Once any NodeClass selects capacity reservations, EKS Auto Mode stops 

87 launching into open ODCRs on its own for every NodeClass in the cluster. 

88 

89 Examples: 

90 gco nodepools create-odcr -n gpu-reserved -r us-east-1 \\ 

91 -c cr-0123456789abcdef0 -i p4d.24xlarge 

92 

93 gco nodepools create-odcr -n ml-training -r us-west-2 \\ 

94 -c cr-0123456789abcdef0 -i p5.48xlarge --fallback-on-demand 

95 

96 gco nodepools create-odcr -n efa-training -r us-east-1 \\ 

97 -c cr-0123456789abcdef0 -i p4d.24xlarge --efa 

98 

99 See: https://docs.aws.amazon.com/eks/latest/userguide/auto-odcr.html 

100 """ 

101 from ..nodepools import generate_odcr_nodepool_manifest, resolve_auto_mode_node_class_inputs 

102 

103 formatter = get_output_formatter(config) 

104 

105 try: 

106 node_class = resolve_auto_mode_node_class_inputs( 

107 cluster or f"{config.project_name}-{region}", region 

108 ) 

109 manifest = generate_odcr_nodepool_manifest( 

110 name=name, 

111 region=region, 

112 capacity_reservation_id=capacity_reservation_id, 

113 node_class=node_class, 

114 instance_types=list(instance_type) if instance_type else None, 

115 max_nodes=max_nodes, 

116 fallback_on_demand=fallback_on_demand, 

117 efa=efa, 

118 ) 

119 _write_or_print_manifest(formatter, manifest, output_file) 

120 

121 except Exception as e: 

122 formatter.print_error(f"Failed to create ODCR NodePool: {e}") 

123 sys.exit(1) 

124 

125 

126@nodepools.command("create-capacity-block") 

127@click.option("--name", "-n", required=True, help="NodePool name") 

128@click.option("--region", "-r", required=True, help="AWS region") 

129@click.option( 

130 "--capacity-reservation-id", 

131 "-c", 

132 required=True, 

133 help="Capacity Reservation ID (cr-xxx) of the purchased Capacity Block", 

134) 

135@click.option( 

136 "--instance-type", 

137 "-i", 

138 multiple=True, 

139 help="Instance types (can specify multiple)", 

140) 

141@click.option("--max-nodes", default=100, help="Maximum nodes in pool") 

142@click.option( 

143 "--fallback-on-demand", 

144 is_flag=True, 

145 help="Fall back to on-demand when the block is exhausted/expired", 

146) 

147@click.option( 

148 "--efa", 

149 is_flag=True, 

150 help="Enable EFA support (adds EFA taint and labels for p4d/p5 instances)", 

151) 

152@click.option("--cluster", help=_CLUSTER_HELP) 

153@click.option("--output-file", "-o", help=_OUTPUT_FILE_HELP) 

154@pass_config 

155def create_capacity_block_nodepool( 

156 config: Any, 

157 name: Any, 

158 region: Any, 

159 capacity_reservation_id: Any, 

160 instance_type: Any, 

161 max_nodes: Any, 

162 fallback_on_demand: Any, 

163 efa: Any, 

164 cluster: Any, 

165 output_file: Any, 

166) -> None: 

167 """Generate a NodePool backed by a purchased Capacity Block. 

168 

169 Purchasing a Capacity Block ('gco capacity reserve') yields a normal EC2 

170 Capacity Reservation ID (cr-xxx). This generates an EKS Auto Mode 

171 NodeClass that binds it through capacityReservationSelectorTerms and a 

172 NodePool that launches reserved capacity through it; Auto Mode never uses 

173 a Capacity Block without such a NodeClass. Because a Capacity Block is 

174 prepaid for a fixed term, the NodePool defaults to holding that capacity 

175 rather than consolidating it away. 

176 

177 Examples: 

178 gco nodepools create-capacity-block -n cb-train -r us-east-1 \\ 

179 -c cr-0123456789abcdef0 -i p5.48xlarge 

180 

181 gco nodepools create-capacity-block -n cb-efa -r us-east-1 \\ 

182 -c cr-0123456789abcdef0 -i p4d.24xlarge --efa 

183 

184 See: https://docs.aws.amazon.com/eks/latest/userguide/auto-odcr.html 

185 """ 

186 from ..nodepools import ( 

187 generate_capacity_block_nodepool_manifest, 

188 resolve_auto_mode_node_class_inputs, 

189 ) 

190 

191 formatter = get_output_formatter(config) 

192 

193 try: 

194 node_class = resolve_auto_mode_node_class_inputs( 

195 cluster or f"{config.project_name}-{region}", region 

196 ) 

197 manifest = generate_capacity_block_nodepool_manifest( 

198 name=name, 

199 region=region, 

200 capacity_reservation_id=capacity_reservation_id, 

201 node_class=node_class, 

202 instance_types=list(instance_type) if instance_type else None, 

203 max_nodes=max_nodes, 

204 fallback_on_demand=fallback_on_demand, 

205 efa=efa, 

206 ) 

207 _write_or_print_manifest(formatter, manifest, output_file) 

208 

209 except Exception as e: 

210 formatter.print_error(f"Failed to create Capacity Block NodePool: {e}") 

211 sys.exit(1) 

212 

213 

214def _api_server_options(func: Any) -> Any: 

215 """``--via-ssm`` / ``--bastion-ttl-minutes`` for the commands that talk to the API server. 

216 

217 The EKS endpoint is private by default, so list, describe and delete reach 

218 it the way ``gco cluster tunnel`` does; the route rules are the same as 

219 every other API-server command's ([Route selection] in docs/CLI.md). 

220 """ 

221 func = click.option( 

222 "--bastion-ttl-minutes", 

223 type=int, 

224 default=DEFAULT_TTL_MINUTES, 

225 show_default=True, 

226 help="Self-terminate backstop (minutes) for an `--via-ssm auto` bastion.", 

227 )(func) 

228 func = click.option( 

229 "--via-ssm", 

230 "via_ssm", 

231 metavar="INSTANCE_ID|auto", 

232 help=( 

233 "Reach the private API endpoint through an SSM-managed instance: an instance " 

234 "id, or 'auto' to provision a self-terminating ephemeral bastion and tear it " 

235 "down afterwards. Used whenever private endpoint access is on." 

236 ), 

237 )(func) 

238 return func 

239 

240 

241@contextmanager 

242def _api_server( 

243 formatter: Any, cluster_name: str, region: str, via_ssm: str | None, ttl: int, yes: bool 

244) -> Iterator[dict[str, str | None]]: 

245 """Yield the ``server``/``tls_server_name`` overrides for the route to ``cluster_name``.""" 

246 from ..cluster_tunnel import open_api_server_tunnel 

247 

248 with open_api_server_tunnel( 

249 formatter, 

250 cluster=cluster_name, 

251 region=region, 

252 via_ssm=via_ssm, 

253 bastion_ttl_minutes=ttl, 

254 assume_yes=yes, 

255 ) as session: 

256 yield {"server": session.server, "tls_server_name": session.tls_server_name} 

257 

258 

259@nodepools.command("delete") 

260@click.argument("nodepool_name") 

261@click.option("--region", "-r", required=True, help="AWS region") 

262@click.option("--cluster", help="EKS cluster name (defaults to <project_name>-<region>)") 

263@_api_server_options 

264@click.option( 

265 "--yes", 

266 "-y", 

267 is_flag=True, 

268 help="Skip the deletion confirmation and the `--via-ssm auto` bastion prompt", 

269) 

270@pass_config 

271def delete_nodepool( 

272 config: Any, 

273 nodepool_name: Any, 

274 region: Any, 

275 cluster: Any, 

276 via_ssm: Any, 

277 bastion_ttl_minutes: Any, 

278 yes: Any, 

279) -> None: 

280 """Delete a NodePool and its paired NodeClass. 

281 

282 Removes the NodePool and the ``<name>-nodeclass`` NodeClass created with 

283 it. EKS Auto Mode drains and terminates any nodes the NodePool 

284 provisioned. Cannot be undone. The EKS endpoint is private by default: 

285 pass --via-ssm auto (or an SSM-managed instance id) to reach it. 

286 

287 Examples: 

288 gco nodepools delete gpu-reserved -r us-east-1 --via-ssm auto 

289 gco nodepools delete gpu-reserved -r us-east-1 --via-ssm auto -y 

290 """ 

291 from ..nodepools import delete_cluster_nodepool 

292 

293 formatter = get_output_formatter(config) 

294 

295 if not yes: 

296 confirm(f"Delete NodePool '{nodepool_name}' in {region}?", abort=True) 

297 

298 try: 

299 cluster_name = cluster or f"{config.project_name}-{region}" 

300 with _api_server( 

301 formatter, cluster_name, region, via_ssm, bastion_ttl_minutes, yes 

302 ) as route: 

303 deleted = delete_cluster_nodepool(cluster_name, region, nodepool_name, **route) 

304 formatter.print_success(f"Deleted NodePool {nodepool_name}") 

305 if deleted.get("nodeclass"): 

306 formatter.print_info(f"Deleted NodeClass {deleted['nodeclass']}") 

307 except Exception as e: 

308 formatter.print_error(f"Failed to delete NodePool: {e}") 

309 sys.exit(1) 

310 

311 

312@nodepools.command("list") 

313@click.option("--region", "-r", help="Filter by region") 

314@click.option("--cluster", help="EKS cluster name (defaults to <project_name>-<region>)") 

315@_api_server_options 

316@click.option( 

317 "--yes", "-y", is_flag=True, help="Skip the confirmation prompt for an `--via-ssm auto` bastion" 

318) 

319@pass_config 

320def list_nodepools( 

321 config: Any, region: Any, cluster: Any, via_ssm: Any, bastion_ttl_minutes: Any, yes: Any 

322) -> None: 

323 """List NodePools in the cluster. 

324 

325 The EKS endpoint is private by default: pass --via-ssm auto (or an 

326 SSM-managed instance id) to reach it. 

327 

328 Examples: 

329 gco nodepools list --region us-east-1 --via-ssm auto -y 

330 gco nodepools list --cluster my-cluster --region us-east-1 

331 """ 

332 from ..nodepools import list_cluster_nodepools 

333 

334 formatter = get_output_formatter(config) 

335 

336 try: 

337 if not region and not cluster: 

338 formatter.print_error("Either --region or --cluster is required") 

339 sys.exit(1) 

340 

341 cluster_name = cluster or f"{config.project_name}-{region}" 

342 target_region = region or config.default_region 

343 with _api_server( 

344 formatter, cluster_name, target_region, via_ssm, bastion_ttl_minutes, yes 

345 ) as route: 

346 nodepools_list = list_cluster_nodepools(cluster_name, target_region, **route) 

347 

348 if not nodepools_list: 

349 formatter.print_info("No NodePools found") 

350 return 

351 

352 formatter.print(nodepools_list) 

353 

354 except Exception as e: 

355 formatter.print_error(f"Failed to list NodePools: {e}") 

356 sys.exit(1) 

357 

358 

359@nodepools.command("describe") 

360@click.argument("nodepool_name") 

361@click.option("--region", "-r", required=True, help="AWS region") 

362@click.option("--cluster", help="EKS cluster name (defaults to <project_name>-<region>)") 

363@_api_server_options 

364@click.option( 

365 "--yes", "-y", is_flag=True, help="Skip the confirmation prompt for an `--via-ssm auto` bastion" 

366) 

367@pass_config 

368def describe_nodepool( 

369 config: Any, 

370 nodepool_name: Any, 

371 region: Any, 

372 cluster: Any, 

373 via_ssm: Any, 

374 bastion_ttl_minutes: Any, 

375 yes: Any, 

376) -> None: 

377 """Describe a NodePool. 

378 

379 The EKS endpoint is private by default: pass --via-ssm auto (or an 

380 SSM-managed instance id) to reach it. 

381 

382 Examples: 

383 gco nodepools describe gpu-x86-pool --region us-east-1 --via-ssm auto -y 

384 """ 

385 from ..nodepools import describe_cluster_nodepool 

386 

387 formatter = get_output_formatter(config) 

388 

389 try: 

390 cluster_name = cluster or f"{config.project_name}-{region}" 

391 with _api_server( 

392 formatter, cluster_name, region, via_ssm, bastion_ttl_minutes, yes 

393 ) as route: 

394 nodepool = describe_cluster_nodepool(cluster_name, region, nodepool_name, **route) 

395 

396 if not nodepool: 

397 formatter.print_error(f"NodePool {nodepool_name} not found") 

398 sys.exit(1) 

399 

400 formatter.print(nodepool) 

401 

402 except Exception as e: 

403 formatter.print_error(f"Failed to describe NodePool: {e}") 

404 sys.exit(1)