aws-bootstrap-g4dn 0.6.0__py3-none-any.whl → 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_bootstrap/cli.py +411 -101
- aws_bootstrap/ec2.py +45 -8
- aws_bootstrap/output.py +106 -0
- aws_bootstrap/ssh.py +21 -20
- aws_bootstrap/tests/test_cli.py +377 -0
- aws_bootstrap/tests/test_ebs.py +90 -0
- aws_bootstrap/tests/test_output.py +192 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/METADATA +34 -1
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/RECORD +13 -11
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/WHEEL +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/entry_points.txt +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/top_level.txt +0 -0
aws_bootstrap/cli.py
CHANGED
|
@@ -17,6 +17,7 @@ from .ec2 import (
|
|
|
17
17
|
delete_ebs_volume,
|
|
18
18
|
ensure_security_group,
|
|
19
19
|
find_ebs_volumes_for_instance,
|
|
20
|
+
find_orphan_ebs_volumes,
|
|
20
21
|
find_tagged_instances,
|
|
21
22
|
get_latest_ami,
|
|
22
23
|
get_spot_price,
|
|
@@ -27,6 +28,7 @@ from .ec2 import (
|
|
|
27
28
|
validate_ebs_volume,
|
|
28
29
|
wait_instance_ready,
|
|
29
30
|
)
|
|
31
|
+
from .output import OutputFormat, emit, is_text
|
|
30
32
|
from .ssh import (
|
|
31
33
|
add_ssh_host,
|
|
32
34
|
cleanup_stale_ssh_hosts,
|
|
@@ -48,22 +50,32 @@ SETUP_SCRIPT = Path(__file__).parent / "resources" / "remote_setup.sh"
|
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
def step(number: int, total: int, msg: str) -> None:
|
|
53
|
+
if not is_text():
|
|
54
|
+
return
|
|
51
55
|
click.secho(f"\n[{number}/{total}] {msg}", bold=True, fg="cyan")
|
|
52
56
|
|
|
53
57
|
|
|
54
58
|
def info(msg: str) -> None:
|
|
59
|
+
if not is_text():
|
|
60
|
+
return
|
|
55
61
|
click.echo(f" {msg}")
|
|
56
62
|
|
|
57
63
|
|
|
58
64
|
def val(label: str, value: str) -> None:
|
|
65
|
+
if not is_text():
|
|
66
|
+
return
|
|
59
67
|
click.echo(f" {label}: " + click.style(str(value), fg="bright_white"))
|
|
60
68
|
|
|
61
69
|
|
|
62
70
|
def success(msg: str) -> None:
|
|
71
|
+
if not is_text():
|
|
72
|
+
return
|
|
63
73
|
click.secho(f" {msg}", fg="green")
|
|
64
74
|
|
|
65
75
|
|
|
66
76
|
def warn(msg: str) -> None:
|
|
77
|
+
if not is_text():
|
|
78
|
+
return
|
|
67
79
|
click.secho(f" WARNING: {msg}", fg="yellow", err=True)
|
|
68
80
|
|
|
69
81
|
|
|
@@ -101,8 +113,19 @@ class _AWSGroup(click.Group):
|
|
|
101
113
|
|
|
102
114
|
@click.group(cls=_AWSGroup)
|
|
103
115
|
@click.version_option(package_name="aws-bootstrap-g4dn")
|
|
104
|
-
|
|
116
|
+
@click.option(
|
|
117
|
+
"--output",
|
|
118
|
+
"-o",
|
|
119
|
+
type=click.Choice(["text", "json", "yaml", "table"], case_sensitive=False),
|
|
120
|
+
default="text",
|
|
121
|
+
show_default=True,
|
|
122
|
+
help="Output format.",
|
|
123
|
+
)
|
|
124
|
+
@click.pass_context
|
|
125
|
+
def main(ctx, output):
|
|
105
126
|
"""Bootstrap AWS EC2 GPU instances for hybrid local-remote development."""
|
|
127
|
+
ctx.ensure_object(dict)
|
|
128
|
+
ctx.obj["output_format"] = OutputFormat(output)
|
|
106
129
|
|
|
107
130
|
|
|
108
131
|
@main.command()
|
|
@@ -141,7 +164,9 @@ def main():
|
|
|
141
164
|
type=str,
|
|
142
165
|
help="Attach an existing EBS volume by ID (e.g. vol-0abc123). Mounted at /data.",
|
|
143
166
|
)
|
|
167
|
+
@click.pass_context
|
|
144
168
|
def launch(
|
|
169
|
+
ctx,
|
|
145
170
|
instance_type,
|
|
146
171
|
ami_filter,
|
|
147
172
|
spot,
|
|
@@ -210,26 +235,48 @@ def launch(
|
|
|
210
235
|
pricing = "spot" if config.spot else "on-demand"
|
|
211
236
|
|
|
212
237
|
if config.dry_run:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
238
|
+
if is_text(ctx):
|
|
239
|
+
click.echo()
|
|
240
|
+
click.secho("--- Dry Run Summary ---", bold=True, fg="yellow")
|
|
241
|
+
val("Instance type", config.instance_type)
|
|
242
|
+
val("AMI", f"{ami['ImageId']} ({ami['Name']})")
|
|
243
|
+
val("Pricing", pricing)
|
|
244
|
+
val("Key pair", config.key_name)
|
|
245
|
+
val("Security group", sg_id)
|
|
246
|
+
val("Volume", f"{config.volume_size} GB gp3")
|
|
247
|
+
val("Region", config.region)
|
|
248
|
+
val("Remote setup", "yes" if config.run_setup else "no")
|
|
249
|
+
if config.ssh_port != 22:
|
|
250
|
+
val("SSH port", str(config.ssh_port))
|
|
251
|
+
if config.python_version:
|
|
252
|
+
val("Python version", config.python_version)
|
|
253
|
+
if config.ebs_storage:
|
|
254
|
+
val("EBS data volume", f"{config.ebs_storage} GB gp3 (new, mounted at {EBS_MOUNT_POINT})")
|
|
255
|
+
if config.ebs_volume_id:
|
|
256
|
+
val("EBS data volume", f"{config.ebs_volume_id} (existing, mounted at {EBS_MOUNT_POINT})")
|
|
257
|
+
click.echo()
|
|
258
|
+
click.secho("No resources launched (dry-run mode).", fg="yellow")
|
|
259
|
+
else:
|
|
260
|
+
result: dict = {
|
|
261
|
+
"dry_run": True,
|
|
262
|
+
"instance_type": config.instance_type,
|
|
263
|
+
"ami_id": ami["ImageId"],
|
|
264
|
+
"ami_name": ami["Name"],
|
|
265
|
+
"pricing": pricing,
|
|
266
|
+
"key_name": config.key_name,
|
|
267
|
+
"security_group": sg_id,
|
|
268
|
+
"volume_size_gb": config.volume_size,
|
|
269
|
+
"region": config.region,
|
|
270
|
+
}
|
|
271
|
+
if config.ssh_port != 22:
|
|
272
|
+
result["ssh_port"] = config.ssh_port
|
|
273
|
+
if config.python_version:
|
|
274
|
+
result["python_version"] = config.python_version
|
|
275
|
+
if config.ebs_storage:
|
|
276
|
+
result["ebs_storage_gb"] = config.ebs_storage
|
|
277
|
+
if config.ebs_volume_id:
|
|
278
|
+
result["ebs_volume_id"] = config.ebs_volume_id
|
|
279
|
+
emit(result, ctx=ctx)
|
|
233
280
|
return
|
|
234
281
|
|
|
235
282
|
# Step 4: Launch instance
|
|
@@ -330,7 +377,30 @@ def launch(
|
|
|
330
377
|
)
|
|
331
378
|
success(f"Added SSH config alias: {alias}")
|
|
332
379
|
|
|
333
|
-
#
|
|
380
|
+
# Structured output for non-text modes
|
|
381
|
+
if not is_text(ctx):
|
|
382
|
+
result_data: dict = {
|
|
383
|
+
"instance_id": instance_id,
|
|
384
|
+
"public_ip": public_ip,
|
|
385
|
+
"instance_type": config.instance_type,
|
|
386
|
+
"availability_zone": az,
|
|
387
|
+
"ami_id": ami["ImageId"],
|
|
388
|
+
"pricing": pricing,
|
|
389
|
+
"region": config.region,
|
|
390
|
+
"ssh_alias": alias,
|
|
391
|
+
}
|
|
392
|
+
if ebs_volume_attached:
|
|
393
|
+
ebs_info: dict = {
|
|
394
|
+
"volume_id": ebs_volume_attached,
|
|
395
|
+
"mount_point": EBS_MOUNT_POINT,
|
|
396
|
+
}
|
|
397
|
+
if config.ebs_storage:
|
|
398
|
+
ebs_info["size_gb"] = config.ebs_storage
|
|
399
|
+
result_data["ebs_volume"] = ebs_info
|
|
400
|
+
emit(result_data, ctx=ctx)
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
# Print connection info (text mode)
|
|
334
404
|
click.echo()
|
|
335
405
|
click.secho("=" * 60, fg="green")
|
|
336
406
|
click.secho(" Instance ready!", bold=True, fg="green")
|
|
@@ -391,44 +461,66 @@ def launch(
|
|
|
391
461
|
show_default=True,
|
|
392
462
|
help="Show connection commands (SSH, Jupyter, VSCode) for each running instance.",
|
|
393
463
|
)
|
|
394
|
-
|
|
464
|
+
@click.pass_context
|
|
465
|
+
def status(ctx, region, profile, gpu, instructions):
|
|
395
466
|
"""Show running instances created by aws-bootstrap."""
|
|
396
467
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
397
468
|
ec2 = session.client("ec2")
|
|
398
469
|
|
|
399
470
|
instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
|
|
400
471
|
if not instances:
|
|
401
|
-
|
|
472
|
+
if is_text(ctx):
|
|
473
|
+
click.secho("No active aws-bootstrap instances found.", fg="yellow")
|
|
474
|
+
else:
|
|
475
|
+
emit({"instances": []}, ctx=ctx)
|
|
402
476
|
return
|
|
403
477
|
|
|
404
478
|
ssh_hosts = list_ssh_hosts()
|
|
405
479
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
480
|
+
if is_text(ctx):
|
|
481
|
+
click.secho(f"\n Found {len(instances)} instance(s):\n", bold=True, fg="cyan")
|
|
482
|
+
if gpu:
|
|
483
|
+
click.echo(" " + click.style("Querying GPU info via SSH...", dim=True))
|
|
484
|
+
click.echo()
|
|
485
|
+
|
|
486
|
+
structured_instances = []
|
|
410
487
|
|
|
411
488
|
for inst in instances:
|
|
412
489
|
state = inst["State"]
|
|
413
|
-
state_color = {
|
|
414
|
-
"running": "green",
|
|
415
|
-
"pending": "yellow",
|
|
416
|
-
"stopping": "yellow",
|
|
417
|
-
"stopped": "red",
|
|
418
|
-
"shutting-down": "red",
|
|
419
|
-
}.get(state, "white")
|
|
420
490
|
alias = ssh_hosts.get(inst["InstanceId"])
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
491
|
+
|
|
492
|
+
# Text mode: inline display
|
|
493
|
+
if is_text(ctx):
|
|
494
|
+
state_color = {
|
|
495
|
+
"running": "green",
|
|
496
|
+
"pending": "yellow",
|
|
497
|
+
"stopping": "yellow",
|
|
498
|
+
"stopped": "red",
|
|
499
|
+
"shutting-down": "red",
|
|
500
|
+
}.get(state, "white")
|
|
501
|
+
alias_str = f" ({alias})" if alias else ""
|
|
502
|
+
click.echo(
|
|
503
|
+
" "
|
|
504
|
+
+ click.style(inst["InstanceId"], fg="bright_white")
|
|
505
|
+
+ click.style(alias_str, fg="cyan")
|
|
506
|
+
+ " "
|
|
507
|
+
+ click.style(state, fg=state_color)
|
|
508
|
+
)
|
|
509
|
+
val(" Type", inst["InstanceType"])
|
|
510
|
+
if inst["PublicIp"]:
|
|
511
|
+
val(" IP", inst["PublicIp"])
|
|
512
|
+
|
|
513
|
+
# Build structured record
|
|
514
|
+
inst_data: dict = {
|
|
515
|
+
"instance_id": inst["InstanceId"],
|
|
516
|
+
"state": state,
|
|
517
|
+
"instance_type": inst["InstanceType"],
|
|
518
|
+
"public_ip": inst["PublicIp"] or None,
|
|
519
|
+
"ssh_alias": alias,
|
|
520
|
+
"lifecycle": inst["Lifecycle"],
|
|
521
|
+
"availability_zone": inst["AvailabilityZone"],
|
|
522
|
+
"launch_time": inst["LaunchTime"],
|
|
523
|
+
}
|
|
432
524
|
|
|
433
525
|
# Look up SSH config details once (used by --gpu and --with-instructions)
|
|
434
526
|
details = None
|
|
@@ -446,51 +538,81 @@ def status(region, profile, gpu, instructions):
|
|
|
446
538
|
Path("~/.ssh/id_ed25519").expanduser(),
|
|
447
539
|
)
|
|
448
540
|
if gpu_info:
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
541
|
+
if is_text(ctx):
|
|
542
|
+
val(" GPU", f"{gpu_info.gpu_name} ({gpu_info.architecture})")
|
|
543
|
+
if gpu_info.cuda_toolkit_version:
|
|
544
|
+
cuda_str = gpu_info.cuda_toolkit_version
|
|
545
|
+
if gpu_info.cuda_driver_version != gpu_info.cuda_toolkit_version:
|
|
546
|
+
cuda_str += f" (driver supports up to {gpu_info.cuda_driver_version})"
|
|
547
|
+
else:
|
|
548
|
+
cuda_str = f"{gpu_info.cuda_driver_version} (driver max, toolkit unknown)"
|
|
549
|
+
val(" CUDA", cuda_str)
|
|
550
|
+
val(" Driver", gpu_info.driver_version)
|
|
551
|
+
inst_data["gpu"] = {
|
|
552
|
+
"name": gpu_info.gpu_name,
|
|
553
|
+
"architecture": gpu_info.architecture,
|
|
554
|
+
"cuda_toolkit": gpu_info.cuda_toolkit_version,
|
|
555
|
+
"cuda_driver_max": gpu_info.cuda_driver_version,
|
|
556
|
+
"driver": gpu_info.driver_version,
|
|
557
|
+
}
|
|
458
558
|
else:
|
|
459
|
-
|
|
559
|
+
if is_text(ctx):
|
|
560
|
+
click.echo(" GPU: " + click.style("unavailable", dim=True))
|
|
460
561
|
|
|
461
562
|
# EBS data volumes
|
|
462
563
|
ebs_volumes = find_ebs_volumes_for_instance(ec2, inst["InstanceId"], "aws-bootstrap-g4dn")
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
564
|
+
if ebs_volumes:
|
|
565
|
+
if is_text(ctx):
|
|
566
|
+
for vol in ebs_volumes:
|
|
567
|
+
vol_state = f", {vol['State']}" if vol["State"] != "in-use" else ""
|
|
568
|
+
val(" EBS", f"{vol['VolumeId']} ({vol['Size']} GB, {EBS_MOUNT_POINT}{vol_state})")
|
|
569
|
+
inst_data["ebs_volumes"] = [
|
|
570
|
+
{
|
|
571
|
+
"volume_id": vol["VolumeId"],
|
|
572
|
+
"size_gb": vol["Size"],
|
|
573
|
+
"mount_point": EBS_MOUNT_POINT,
|
|
574
|
+
"state": vol["State"],
|
|
575
|
+
}
|
|
576
|
+
for vol in ebs_volumes
|
|
577
|
+
]
|
|
466
578
|
|
|
467
579
|
lifecycle = inst["Lifecycle"]
|
|
468
580
|
is_spot = lifecycle == "spot"
|
|
581
|
+
spot_price = None
|
|
469
582
|
|
|
470
583
|
if is_spot:
|
|
471
584
|
spot_price = get_spot_price(ec2, inst["InstanceType"], inst["AvailabilityZone"])
|
|
585
|
+
if is_text(ctx):
|
|
586
|
+
if spot_price is not None:
|
|
587
|
+
val(" Pricing", f"spot (${spot_price:.4f}/hr)")
|
|
588
|
+
else:
|
|
589
|
+
val(" Pricing", "spot")
|
|
472
590
|
if spot_price is not None:
|
|
473
|
-
|
|
474
|
-
else:
|
|
475
|
-
val(" Pricing", "spot")
|
|
591
|
+
inst_data["spot_price_per_hour"] = spot_price
|
|
476
592
|
else:
|
|
477
|
-
|
|
593
|
+
if is_text(ctx):
|
|
594
|
+
val(" Pricing", "on-demand")
|
|
478
595
|
|
|
479
596
|
if state == "running" and is_spot:
|
|
480
597
|
uptime = datetime.now(UTC) - inst["LaunchTime"]
|
|
481
598
|
total_seconds = int(uptime.total_seconds())
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
599
|
+
inst_data["uptime_seconds"] = total_seconds
|
|
600
|
+
if is_text(ctx):
|
|
601
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
602
|
+
minutes = remainder // 60
|
|
603
|
+
val(" Uptime", f"{hours}h {minutes:02d}m")
|
|
485
604
|
if spot_price is not None:
|
|
486
605
|
uptime_hours = uptime.total_seconds() / 3600
|
|
487
606
|
est_cost = uptime_hours * spot_price
|
|
488
|
-
|
|
607
|
+
inst_data["estimated_cost"] = round(est_cost, 4)
|
|
608
|
+
if is_text(ctx):
|
|
609
|
+
val(" Est. cost", f"~${est_cost:.4f}")
|
|
489
610
|
|
|
490
|
-
|
|
611
|
+
if is_text(ctx):
|
|
612
|
+
val(" Launched", str(inst["LaunchTime"]))
|
|
491
613
|
|
|
492
614
|
# Connection instructions (opt-in, only for running instances with a public IP and alias)
|
|
493
|
-
if instructions and state == "running" and inst["PublicIp"] and alias:
|
|
615
|
+
if is_text(ctx) and instructions and state == "running" and inst["PublicIp"] and alias:
|
|
494
616
|
user = details.user if details else "ubuntu"
|
|
495
617
|
port = details.port if details else 22
|
|
496
618
|
port_flag = f" -p {port}" if port != 22 else ""
|
|
@@ -511,6 +633,24 @@ def status(region, profile, gpu, instructions):
|
|
|
511
633
|
click.secho(" GPU Benchmark:", fg="cyan")
|
|
512
634
|
click.secho(f" ssh {alias} 'python ~/gpu_benchmark.py'", bold=True)
|
|
513
635
|
|
|
636
|
+
structured_instances.append(inst_data)
|
|
637
|
+
|
|
638
|
+
if not is_text(ctx):
|
|
639
|
+
emit(
|
|
640
|
+
{"instances": structured_instances},
|
|
641
|
+
headers={
|
|
642
|
+
"instance_id": "Instance ID",
|
|
643
|
+
"state": "State",
|
|
644
|
+
"instance_type": "Type",
|
|
645
|
+
"public_ip": "IP",
|
|
646
|
+
"ssh_alias": "Alias",
|
|
647
|
+
"lifecycle": "Pricing",
|
|
648
|
+
"uptime_seconds": "Uptime (s)",
|
|
649
|
+
},
|
|
650
|
+
ctx=ctx,
|
|
651
|
+
)
|
|
652
|
+
return
|
|
653
|
+
|
|
514
654
|
click.echo()
|
|
515
655
|
first_id = instances[0]["InstanceId"]
|
|
516
656
|
first_ref = ssh_hosts.get(first_id, first_id)
|
|
@@ -524,7 +664,8 @@ def status(region, profile, gpu, instructions):
|
|
|
524
664
|
@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt.")
|
|
525
665
|
@click.option("--keep-ebs", is_flag=True, default=False, help="Preserve EBS data volumes instead of deleting them.")
|
|
526
666
|
@click.argument("instance_ids", nargs=-1, metavar="[INSTANCE_ID_OR_ALIAS]...")
|
|
527
|
-
|
|
667
|
+
@click.pass_context
|
|
668
|
+
def terminate(ctx, region, profile, yes, keep_ebs, instance_ids):
|
|
528
669
|
"""Terminate instances created by aws-bootstrap.
|
|
529
670
|
|
|
530
671
|
Pass specific instance IDs or SSH aliases (e.g. aws-gpu1) to terminate,
|
|
@@ -533,6 +674,10 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
|
|
|
533
674
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
534
675
|
ec2 = session.client("ec2")
|
|
535
676
|
|
|
677
|
+
# In structured output modes, require --yes (prompts would corrupt output)
|
|
678
|
+
if not is_text(ctx) and not yes:
|
|
679
|
+
raise CLIError("--yes is required when using structured output (--output json/yaml/table).")
|
|
680
|
+
|
|
536
681
|
if instance_ids:
|
|
537
682
|
targets = []
|
|
538
683
|
for value in instance_ids:
|
|
@@ -548,13 +693,17 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
|
|
|
548
693
|
else:
|
|
549
694
|
instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
|
|
550
695
|
if not instances:
|
|
551
|
-
|
|
696
|
+
if is_text(ctx):
|
|
697
|
+
click.secho("No active aws-bootstrap instances found.", fg="yellow")
|
|
698
|
+
else:
|
|
699
|
+
emit({"terminated": []}, ctx=ctx)
|
|
552
700
|
return
|
|
553
701
|
targets = [inst["InstanceId"] for inst in instances]
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
702
|
+
if is_text(ctx):
|
|
703
|
+
click.secho(f"\n Found {len(targets)} instance(s) to terminate:\n", bold=True, fg="cyan")
|
|
704
|
+
for inst in instances:
|
|
705
|
+
iid = click.style(inst["InstanceId"], fg="bright_white")
|
|
706
|
+
click.echo(f" {iid} {inst['State']} {inst['InstanceType']}")
|
|
558
707
|
|
|
559
708
|
if not yes:
|
|
560
709
|
click.echo()
|
|
@@ -570,36 +719,59 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
|
|
|
570
719
|
ebs_by_instance[target] = volumes
|
|
571
720
|
|
|
572
721
|
changes = terminate_tagged_instances(ec2, targets)
|
|
573
|
-
|
|
722
|
+
|
|
723
|
+
terminated_results = []
|
|
724
|
+
|
|
725
|
+
if is_text(ctx):
|
|
726
|
+
click.echo()
|
|
574
727
|
for change in changes:
|
|
575
728
|
prev = change["PreviousState"]["Name"]
|
|
576
729
|
curr = change["CurrentState"]["Name"]
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
removed_alias = remove_ssh_host(
|
|
730
|
+
iid = change["InstanceId"]
|
|
731
|
+
if is_text(ctx):
|
|
732
|
+
click.echo(" " + click.style(iid, fg="bright_white") + f" {prev} -> " + click.style(curr, fg="red"))
|
|
733
|
+
removed_alias = remove_ssh_host(iid)
|
|
581
734
|
if removed_alias:
|
|
582
735
|
info(f"Removed SSH config alias: {removed_alias}")
|
|
583
736
|
|
|
737
|
+
change_data: dict = {
|
|
738
|
+
"instance_id": iid,
|
|
739
|
+
"previous_state": prev,
|
|
740
|
+
"current_state": curr,
|
|
741
|
+
}
|
|
742
|
+
if removed_alias:
|
|
743
|
+
change_data["ssh_alias_removed"] = removed_alias
|
|
744
|
+
terminated_results.append(change_data)
|
|
745
|
+
|
|
584
746
|
# Handle EBS volume cleanup
|
|
585
747
|
for _iid, volumes in ebs_by_instance.items():
|
|
586
748
|
for vol in volumes:
|
|
587
749
|
vid = vol["VolumeId"]
|
|
588
750
|
if keep_ebs:
|
|
589
|
-
|
|
751
|
+
if is_text(ctx):
|
|
752
|
+
click.echo()
|
|
590
753
|
info(f"Preserving EBS volume: {vid} ({vol['Size']} GB)")
|
|
591
754
|
info(f"Reattach with: aws-bootstrap launch --ebs-volume-id {vid}")
|
|
592
755
|
else:
|
|
593
|
-
|
|
756
|
+
if is_text(ctx):
|
|
757
|
+
click.echo()
|
|
594
758
|
info(f"Waiting for EBS volume {vid} to detach...")
|
|
595
759
|
try:
|
|
596
760
|
waiter = ec2.get_waiter("volume_available")
|
|
597
761
|
waiter.wait(VolumeIds=[vid], WaiterConfig={"Delay": 10, "MaxAttempts": 30})
|
|
598
762
|
delete_ebs_volume(ec2, vid)
|
|
599
763
|
success(f"Deleted EBS volume: {vid}")
|
|
764
|
+
# Record deleted volume in the corresponding terminated result
|
|
765
|
+
for tr in terminated_results:
|
|
766
|
+
if tr["instance_id"] == _iid:
|
|
767
|
+
tr.setdefault("ebs_volumes_deleted", []).append(vid)
|
|
600
768
|
except Exception as e:
|
|
601
769
|
warn(f"Failed to delete EBS volume {vid}: {e}")
|
|
602
770
|
|
|
771
|
+
if not is_text(ctx):
|
|
772
|
+
emit({"terminated": terminated_results}, ctx=ctx)
|
|
773
|
+
return
|
|
774
|
+
|
|
603
775
|
click.echo()
|
|
604
776
|
success(f"Terminated {len(changes)} instance(s).")
|
|
605
777
|
|
|
@@ -607,44 +779,130 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
|
|
|
607
779
|
@main.command()
|
|
608
780
|
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be removed without removing.")
|
|
609
781
|
@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt.")
|
|
782
|
+
@click.option("--include-ebs", is_flag=True, default=False, help="Also find and delete orphan EBS data volumes.")
|
|
610
783
|
@click.option("--region", default="us-west-2", show_default=True, help="AWS region.")
|
|
611
784
|
@click.option("--profile", default=None, help="AWS profile override.")
|
|
612
|
-
|
|
785
|
+
@click.pass_context
|
|
786
|
+
def cleanup(ctx, dry_run, yes, include_ebs, region, profile):
|
|
613
787
|
"""Remove stale SSH config entries for terminated instances."""
|
|
614
788
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
615
789
|
ec2 = session.client("ec2")
|
|
616
790
|
|
|
791
|
+
# In structured output modes, require --yes for non-dry-run (prompts would corrupt output)
|
|
792
|
+
if not is_text(ctx) and not yes and not dry_run:
|
|
793
|
+
raise CLIError("--yes is required when using structured output (--output json/yaml/table).")
|
|
794
|
+
|
|
617
795
|
live_instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
|
|
618
796
|
live_ids = {inst["InstanceId"] for inst in live_instances}
|
|
619
797
|
|
|
620
798
|
stale = find_stale_ssh_hosts(live_ids)
|
|
621
|
-
|
|
622
|
-
|
|
799
|
+
|
|
800
|
+
# Orphan EBS discovery
|
|
801
|
+
orphan_volumes: list[dict] = []
|
|
802
|
+
if include_ebs:
|
|
803
|
+
orphan_volumes = find_orphan_ebs_volumes(ec2, "aws-bootstrap-g4dn", live_ids)
|
|
804
|
+
|
|
805
|
+
if not stale and not orphan_volumes:
|
|
806
|
+
if is_text(ctx):
|
|
807
|
+
msg = "No stale SSH config entries found."
|
|
808
|
+
if include_ebs:
|
|
809
|
+
msg = "No stale SSH config entries or orphan EBS volumes found."
|
|
810
|
+
click.secho(msg, fg="green")
|
|
811
|
+
else:
|
|
812
|
+
result_key = "stale" if dry_run else "cleaned"
|
|
813
|
+
result: dict = {result_key: []}
|
|
814
|
+
if include_ebs:
|
|
815
|
+
ebs_key = "orphan_volumes" if dry_run else "deleted_volumes"
|
|
816
|
+
result[ebs_key] = []
|
|
817
|
+
emit(result, ctx=ctx)
|
|
623
818
|
return
|
|
624
819
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
820
|
+
if is_text(ctx):
|
|
821
|
+
if stale:
|
|
822
|
+
click.secho(f"\n Found {len(stale)} stale SSH config entry(ies):\n", bold=True, fg="cyan")
|
|
823
|
+
for iid, alias in stale:
|
|
824
|
+
click.echo(" " + click.style(alias, fg="bright_white") + f" ({iid})")
|
|
825
|
+
if orphan_volumes:
|
|
826
|
+
click.secho(f"\n Found {len(orphan_volumes)} orphan EBS volume(s):\n", bold=True, fg="cyan")
|
|
827
|
+
for vol in orphan_volumes:
|
|
828
|
+
click.echo(
|
|
829
|
+
" "
|
|
830
|
+
+ click.style(vol["VolumeId"], fg="bright_white")
|
|
831
|
+
+ f" ({vol['Size']} GB, was {vol['InstanceId']})"
|
|
832
|
+
)
|
|
628
833
|
|
|
629
834
|
if dry_run:
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
835
|
+
if is_text(ctx):
|
|
836
|
+
click.echo()
|
|
837
|
+
for iid, alias in stale:
|
|
838
|
+
info(f"Would remove {alias} ({iid})")
|
|
839
|
+
for vol in orphan_volumes:
|
|
840
|
+
info(f"Would delete {vol['VolumeId']} ({vol['Size']} GB)")
|
|
841
|
+
else:
|
|
842
|
+
result = {
|
|
843
|
+
"stale": [{"instance_id": iid, "alias": alias} for iid, alias in stale],
|
|
844
|
+
"dry_run": True,
|
|
845
|
+
}
|
|
846
|
+
if include_ebs:
|
|
847
|
+
result["orphan_volumes"] = [
|
|
848
|
+
{
|
|
849
|
+
"volume_id": vol["VolumeId"],
|
|
850
|
+
"size_gb": vol["Size"],
|
|
851
|
+
"instance_id": vol["InstanceId"],
|
|
852
|
+
}
|
|
853
|
+
for vol in orphan_volumes
|
|
854
|
+
]
|
|
855
|
+
emit(result, ctx=ctx)
|
|
633
856
|
return
|
|
634
857
|
|
|
635
858
|
if not yes:
|
|
636
859
|
click.echo()
|
|
637
|
-
|
|
860
|
+
parts = []
|
|
861
|
+
if stale:
|
|
862
|
+
parts.append(f"{len(stale)} stale SSH entry(ies)")
|
|
863
|
+
if orphan_volumes:
|
|
864
|
+
parts.append(f"{len(orphan_volumes)} orphan EBS volume(s)")
|
|
865
|
+
if not click.confirm(f" Remove {' and '.join(parts)}?"):
|
|
638
866
|
click.secho(" Cancelled.", fg="yellow")
|
|
639
867
|
return
|
|
640
868
|
|
|
641
|
-
|
|
869
|
+
ssh_results = cleanup_stale_ssh_hosts(live_ids) if stale else []
|
|
870
|
+
|
|
871
|
+
# Delete orphan EBS volumes
|
|
872
|
+
deleted_volumes: list[dict] = []
|
|
873
|
+
for vol in orphan_volumes:
|
|
874
|
+
try:
|
|
875
|
+
delete_ebs_volume(ec2, vol["VolumeId"])
|
|
876
|
+
deleted_volumes.append({"volume_id": vol["VolumeId"], "size_gb": vol["Size"], "deleted": True})
|
|
877
|
+
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as exc:
|
|
878
|
+
if is_text(ctx):
|
|
879
|
+
warn(f"Failed to delete {vol['VolumeId']}: {exc}")
|
|
880
|
+
deleted_volumes.append({"volume_id": vol["VolumeId"], "size_gb": vol["Size"], "deleted": False})
|
|
881
|
+
|
|
882
|
+
if not is_text(ctx):
|
|
883
|
+
result = {
|
|
884
|
+
"cleaned": [{"instance_id": r.instance_id, "alias": r.alias, "removed": r.removed} for r in ssh_results],
|
|
885
|
+
}
|
|
886
|
+
if include_ebs:
|
|
887
|
+
result["deleted_volumes"] = deleted_volumes
|
|
888
|
+
emit(result, ctx=ctx)
|
|
889
|
+
return
|
|
890
|
+
|
|
642
891
|
click.echo()
|
|
643
|
-
for r in
|
|
892
|
+
for r in ssh_results:
|
|
644
893
|
success(f"Removed {r.alias} ({r.instance_id})")
|
|
894
|
+
for vol in deleted_volumes:
|
|
895
|
+
if vol["deleted"]:
|
|
896
|
+
success(f"Deleted {vol['volume_id']} ({vol['size_gb']} GB)")
|
|
645
897
|
|
|
646
898
|
click.echo()
|
|
647
|
-
|
|
899
|
+
parts = []
|
|
900
|
+
if ssh_results:
|
|
901
|
+
parts.append(f"{len(ssh_results)} stale entry(ies)")
|
|
902
|
+
if deleted_volumes:
|
|
903
|
+
ok_count = sum(1 for v in deleted_volumes if v["deleted"])
|
|
904
|
+
parts.append(f"{ok_count} orphan volume(s)")
|
|
905
|
+
success(f"Cleaned up {' and '.join(parts)}.")
|
|
648
906
|
|
|
649
907
|
|
|
650
908
|
# ---------------------------------------------------------------------------
|
|
@@ -663,14 +921,40 @@ def list_cmd():
|
|
|
663
921
|
@click.option("--prefix", default="g4dn", show_default=True, help="Instance type family prefix to filter on.")
|
|
664
922
|
@click.option("--region", default="us-west-2", show_default=True, help="AWS region.")
|
|
665
923
|
@click.option("--profile", default=None, help="AWS profile override.")
|
|
666
|
-
|
|
924
|
+
@click.pass_context
|
|
925
|
+
def list_instance_types_cmd(ctx, prefix, region, profile):
|
|
667
926
|
"""List EC2 instance types matching a family prefix (e.g. g4dn, p3, g5)."""
|
|
668
927
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
669
928
|
ec2 = session.client("ec2")
|
|
670
929
|
|
|
671
930
|
types = list_instance_types(ec2, prefix)
|
|
672
931
|
if not types:
|
|
673
|
-
|
|
932
|
+
if is_text(ctx):
|
|
933
|
+
click.secho(f"No instance types found matching '{prefix}.*'", fg="yellow")
|
|
934
|
+
else:
|
|
935
|
+
emit([], ctx=ctx)
|
|
936
|
+
return
|
|
937
|
+
|
|
938
|
+
if not is_text(ctx):
|
|
939
|
+
structured = [
|
|
940
|
+
{
|
|
941
|
+
"instance_type": t["InstanceType"],
|
|
942
|
+
"vcpus": t["VCpuCount"],
|
|
943
|
+
"memory_mib": t["MemoryMiB"],
|
|
944
|
+
"gpu": t["GpuSummary"] or None,
|
|
945
|
+
}
|
|
946
|
+
for t in types
|
|
947
|
+
]
|
|
948
|
+
emit(
|
|
949
|
+
structured,
|
|
950
|
+
headers={
|
|
951
|
+
"instance_type": "Instance Type",
|
|
952
|
+
"vcpus": "vCPUs",
|
|
953
|
+
"memory_mib": "Memory (MiB)",
|
|
954
|
+
"gpu": "GPU",
|
|
955
|
+
},
|
|
956
|
+
ctx=ctx,
|
|
957
|
+
)
|
|
674
958
|
return
|
|
675
959
|
|
|
676
960
|
click.secho(f"\n {len(types)} instance type(s) matching '{prefix}.*':\n", bold=True, fg="cyan")
|
|
@@ -682,8 +966,8 @@ def list_instance_types_cmd(prefix, region, profile):
|
|
|
682
966
|
click.echo(" " + "-" * 72)
|
|
683
967
|
|
|
684
968
|
for t in types:
|
|
685
|
-
|
|
686
|
-
click.echo(f" {t['InstanceType']:<24}{t['VCpuCount']:>6}{t['MemoryMiB']:>14} {
|
|
969
|
+
gpu_str = t["GpuSummary"] or "-"
|
|
970
|
+
click.echo(f" {t['InstanceType']:<24}{t['VCpuCount']:>6}{t['MemoryMiB']:>14} {gpu_str}")
|
|
687
971
|
|
|
688
972
|
click.echo()
|
|
689
973
|
|
|
@@ -692,14 +976,40 @@ def list_instance_types_cmd(prefix, region, profile):
|
|
|
692
976
|
@click.option("--filter", "ami_filter", default=DEFAULT_AMI_PREFIX, show_default=True, help="AMI name pattern.")
|
|
693
977
|
@click.option("--region", default="us-west-2", show_default=True, help="AWS region.")
|
|
694
978
|
@click.option("--profile", default=None, help="AWS profile override.")
|
|
695
|
-
|
|
979
|
+
@click.pass_context
|
|
980
|
+
def list_amis_cmd(ctx, ami_filter, region, profile):
|
|
696
981
|
"""List available AMIs matching a name pattern."""
|
|
697
982
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
698
983
|
ec2 = session.client("ec2")
|
|
699
984
|
|
|
700
985
|
amis = list_amis(ec2, ami_filter)
|
|
701
986
|
if not amis:
|
|
702
|
-
|
|
987
|
+
if is_text(ctx):
|
|
988
|
+
click.secho(f"No AMIs found matching '{ami_filter}'", fg="yellow")
|
|
989
|
+
else:
|
|
990
|
+
emit([], ctx=ctx)
|
|
991
|
+
return
|
|
992
|
+
|
|
993
|
+
if not is_text(ctx):
|
|
994
|
+
structured = [
|
|
995
|
+
{
|
|
996
|
+
"image_id": ami["ImageId"],
|
|
997
|
+
"name": ami["Name"],
|
|
998
|
+
"creation_date": ami["CreationDate"][:10],
|
|
999
|
+
"architecture": ami["Architecture"],
|
|
1000
|
+
}
|
|
1001
|
+
for ami in amis
|
|
1002
|
+
]
|
|
1003
|
+
emit(
|
|
1004
|
+
structured,
|
|
1005
|
+
headers={
|
|
1006
|
+
"image_id": "Image ID",
|
|
1007
|
+
"name": "Name",
|
|
1008
|
+
"creation_date": "Created",
|
|
1009
|
+
"architecture": "Arch",
|
|
1010
|
+
},
|
|
1011
|
+
ctx=ctx,
|
|
1012
|
+
)
|
|
703
1013
|
return
|
|
704
1014
|
|
|
705
1015
|
click.secho(f"\n {len(amis)} AMI(s) matching '{ami_filter}' (newest first):\n", bold=True, fg="cyan")
|