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
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""NodePool commands."""
3import sys
4from collections.abc import Iterator
5from contextlib import contextmanager
6from typing import Any
8import click
10from ..config import GCOConfig
11from ..ephemeral_bastion import DEFAULT_TTL_MINUTES
12from ..output import confirm, get_output_formatter
14pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
17@click.group()
18@pass_config
19def nodepools(config: Any) -> None:
20 """Manage Karpenter NodePools with ODCR/Capacity Reservation support."""
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"
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")
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).
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>.
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.
89 Examples:
90 gco nodepools create-odcr -n gpu-reserved -r us-east-1 \\
91 -c cr-0123456789abcdef0 -i p4d.24xlarge
93 gco nodepools create-odcr -n ml-training -r us-west-2 \\
94 -c cr-0123456789abcdef0 -i p5.48xlarge --fallback-on-demand
96 gco nodepools create-odcr -n efa-training -r us-east-1 \\
97 -c cr-0123456789abcdef0 -i p4d.24xlarge --efa
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
103 formatter = get_output_formatter(config)
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)
121 except Exception as e:
122 formatter.print_error(f"Failed to create ODCR NodePool: {e}")
123 sys.exit(1)
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.
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.
177 Examples:
178 gco nodepools create-capacity-block -n cb-train -r us-east-1 \\
179 -c cr-0123456789abcdef0 -i p5.48xlarge
181 gco nodepools create-capacity-block -n cb-efa -r us-east-1 \\
182 -c cr-0123456789abcdef0 -i p4d.24xlarge --efa
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 )
191 formatter = get_output_formatter(config)
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)
209 except Exception as e:
210 formatter.print_error(f"Failed to create Capacity Block NodePool: {e}")
211 sys.exit(1)
214def _api_server_options(func: Any) -> Any:
215 """``--via-ssm`` / ``--bastion-ttl-minutes`` for the commands that talk to the API server.
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
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
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}
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.
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.
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
293 formatter = get_output_formatter(config)
295 if not yes:
296 confirm(f"Delete NodePool '{nodepool_name}' in {region}?", abort=True)
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)
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.
325 The EKS endpoint is private by default: pass --via-ssm auto (or an
326 SSM-managed instance id) to reach it.
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
334 formatter = get_output_formatter(config)
336 try:
337 if not region and not cluster:
338 formatter.print_error("Either --region or --cluster is required")
339 sys.exit(1)
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)
348 if not nodepools_list:
349 formatter.print_info("No NodePools found")
350 return
352 formatter.print(nodepools_list)
354 except Exception as e:
355 formatter.print_error(f"Failed to list NodePools: {e}")
356 sys.exit(1)
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.
379 The EKS endpoint is private by default: pass --via-ssm auto (or an
380 SSM-managed instance id) to reach it.
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
387 formatter = get_output_formatter(config)
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)
396 if not nodepool:
397 formatter.print_error(f"NodePool {nodepool_name} not found")
398 sys.exit(1)
400 formatter.print(nodepool)
402 except Exception as e:
403 formatter.print_error(f"Failed to describe NodePool: {e}")
404 sys.exit(1)