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 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
- def main():
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
- click.echo()
214
- click.secho("--- Dry Run Summary ---", bold=True, fg="yellow")
215
- val("Instance type", config.instance_type)
216
- val("AMI", f"{ami['ImageId']} ({ami['Name']})")
217
- val("Pricing", pricing)
218
- val("Key pair", config.key_name)
219
- val("Security group", sg_id)
220
- val("Volume", f"{config.volume_size} GB gp3")
221
- val("Region", config.region)
222
- val("Remote setup", "yes" if config.run_setup else "no")
223
- if config.ssh_port != 22:
224
- val("SSH port", str(config.ssh_port))
225
- if config.python_version:
226
- val("Python version", config.python_version)
227
- if config.ebs_storage:
228
- val("EBS data volume", f"{config.ebs_storage} GB gp3 (new, mounted at {EBS_MOUNT_POINT})")
229
- if config.ebs_volume_id:
230
- val("EBS data volume", f"{config.ebs_volume_id} (existing, mounted at {EBS_MOUNT_POINT})")
231
- click.echo()
232
- click.secho("No resources launched (dry-run mode).", fg="yellow")
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
- # Print connection info
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
- def status(region, profile, gpu, instructions):
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
- click.secho("No active aws-bootstrap instances found.", fg="yellow")
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
- click.secho(f"\n Found {len(instances)} instance(s):\n", bold=True, fg="cyan")
407
- if gpu:
408
- click.echo(" " + click.style("Querying GPU info via SSH...", dim=True))
409
- click.echo()
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
- alias_str = f" ({alias})" if alias else ""
422
- click.echo(
423
- " "
424
- + click.style(inst["InstanceId"], fg="bright_white")
425
- + click.style(alias_str, fg="cyan")
426
- + " "
427
- + click.style(state, fg=state_color)
428
- )
429
- val(" Type", inst["InstanceType"])
430
- if inst["PublicIp"]:
431
- val(" IP", inst["PublicIp"])
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
- val(" GPU", f"{gpu_info.gpu_name} ({gpu_info.architecture})")
450
- if gpu_info.cuda_toolkit_version:
451
- cuda_str = gpu_info.cuda_toolkit_version
452
- if gpu_info.cuda_driver_version != gpu_info.cuda_toolkit_version:
453
- cuda_str += f" (driver supports up to {gpu_info.cuda_driver_version})"
454
- else:
455
- cuda_str = f"{gpu_info.cuda_driver_version} (driver max, toolkit unknown)"
456
- val(" CUDA", cuda_str)
457
- val(" Driver", gpu_info.driver_version)
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
- click.echo(" GPU: " + click.style("unavailable", dim=True))
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
- for vol in ebs_volumes:
464
- vol_state = f", {vol['State']}" if vol["State"] != "in-use" else ""
465
- val(" EBS", f"{vol['VolumeId']} ({vol['Size']} GB, {EBS_MOUNT_POINT}{vol_state})")
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
- val(" Pricing", f"spot (${spot_price:.4f}/hr)")
474
- else:
475
- val(" Pricing", "spot")
591
+ inst_data["spot_price_per_hour"] = spot_price
476
592
  else:
477
- val(" Pricing", "on-demand")
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
- hours, remainder = divmod(total_seconds, 3600)
483
- minutes = remainder // 60
484
- val(" Uptime", f"{hours}h {minutes:02d}m")
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
- val(" Est. cost", f"~${est_cost:.4f}")
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
- val(" Launched", str(inst["LaunchTime"]))
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
- def terminate(region, profile, yes, keep_ebs, instance_ids):
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
- click.secho("No active aws-bootstrap instances found.", fg="yellow")
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
- click.secho(f"\n Found {len(targets)} instance(s) to terminate:\n", bold=True, fg="cyan")
555
- for inst in instances:
556
- iid = click.style(inst["InstanceId"], fg="bright_white")
557
- click.echo(f" {iid} {inst['State']} {inst['InstanceType']}")
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
- click.echo()
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
- click.echo(
578
- " " + click.style(change["InstanceId"], fg="bright_white") + f" {prev} -> " + click.style(curr, fg="red")
579
- )
580
- removed_alias = remove_ssh_host(change["InstanceId"])
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
- click.echo()
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
- click.echo()
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
- def cleanup(dry_run, yes, region, profile):
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
- if not stale:
622
- click.secho("No stale SSH config entries found.", fg="green")
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
- click.secho(f"\n Found {len(stale)} stale SSH config entry(ies):\n", bold=True, fg="cyan")
626
- for iid, alias in stale:
627
- click.echo(" " + click.style(alias, fg="bright_white") + f" ({iid})")
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
- click.echo()
631
- for iid, alias in stale:
632
- info(f"Would remove {alias} ({iid})")
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
- if not click.confirm(f" Remove {len(stale)} stale entry(ies)?"):
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
- results = cleanup_stale_ssh_hosts(live_ids)
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 results:
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
- success(f"Cleaned up {len(results)} stale entry(ies).")
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
- def list_instance_types_cmd(prefix, region, profile):
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
- click.secho(f"No instance types found matching '{prefix}.*'", fg="yellow")
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
- gpu = t["GpuSummary"] or "-"
686
- click.echo(f" {t['InstanceType']:<24}{t['VCpuCount']:>6}{t['MemoryMiB']:>14} {gpu}")
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
- def list_amis_cmd(ami_filter, region, profile):
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
- click.secho(f"No AMIs found matching '{ami_filter}'", fg="yellow")
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")