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