aws-bootstrap-g4dn 0.5.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.
@@ -1,16 +1,18 @@
1
1
  """Tests for CLI entry point and help output."""
2
2
 
3
3
  from __future__ import annotations
4
+ import json
4
5
  from datetime import UTC, datetime
5
6
  from pathlib import Path
6
- from unittest.mock import patch
7
+ from unittest.mock import MagicMock, patch
7
8
 
8
9
  import botocore.exceptions
10
+ import yaml
9
11
  from click.testing import CliRunner
10
12
 
11
13
  from aws_bootstrap.cli import main
12
14
  from aws_bootstrap.gpu import GpuInfo
13
- from aws_bootstrap.ssh import SSHHostDetails
15
+ from aws_bootstrap.ssh import CleanupResult, SSHHostDetails
14
16
 
15
17
 
16
18
  def test_help():
@@ -347,7 +349,10 @@ def test_launch_output_shows_ssh_alias(
347
349
  ):
348
350
  mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
349
351
  mock_launch.return_value = {"InstanceId": "i-test123"}
350
- mock_wait.return_value = {"PublicIpAddress": "1.2.3.4"}
352
+ mock_wait.return_value = {
353
+ "PublicIpAddress": "1.2.3.4",
354
+ "Placement": {"AvailabilityZone": "us-west-2a"},
355
+ }
351
356
 
352
357
  key_path = tmp_path / "id_ed25519.pub"
353
358
  key_path.write_text("ssh-ed25519 AAAA test@host")
@@ -799,7 +804,10 @@ def test_launch_python_version_passed_to_setup(
799
804
  ):
800
805
  mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
801
806
  mock_launch.return_value = {"InstanceId": "i-test123"}
802
- mock_wait.return_value = {"PublicIpAddress": "1.2.3.4"}
807
+ mock_wait.return_value = {
808
+ "PublicIpAddress": "1.2.3.4",
809
+ "Placement": {"AvailabilityZone": "us-west-2a"},
810
+ }
803
811
 
804
812
  key_path = tmp_path / "id_ed25519.pub"
805
813
  key_path.write_text("ssh-ed25519 AAAA test@host")
@@ -886,3 +894,643 @@ def test_launch_dry_run_omits_ssh_port_when_default(mock_sg, mock_import, mock_a
886
894
  result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--dry-run"])
887
895
  assert result.exit_code == 0
888
896
  assert "SSH port" not in result.output
897
+
898
+
899
+ # ---------------------------------------------------------------------------
900
+ # EBS data volume tests
901
+ # ---------------------------------------------------------------------------
902
+
903
+
904
+ def test_launch_help_shows_ebs_options():
905
+ runner = CliRunner()
906
+ result = runner.invoke(main, ["launch", "--help"])
907
+ assert result.exit_code == 0
908
+ assert "--ebs-storage" in result.output
909
+ assert "--ebs-volume-id" in result.output
910
+
911
+
912
+ def test_launch_ebs_mutual_exclusivity(tmp_path):
913
+ key_path = tmp_path / "id_ed25519.pub"
914
+ key_path.write_text("ssh-ed25519 AAAA test@host")
915
+
916
+ runner = CliRunner()
917
+ result = runner.invoke(
918
+ main, ["launch", "--key-path", str(key_path), "--ebs-storage", "96", "--ebs-volume-id", "vol-abc"]
919
+ )
920
+ assert result.exit_code != 0
921
+ assert "mutually exclusive" in result.output
922
+
923
+
924
+ @patch("aws_bootstrap.cli.boto3.Session")
925
+ @patch("aws_bootstrap.cli.get_latest_ami")
926
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
927
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
928
+ def test_launch_dry_run_with_ebs_storage(mock_sg, mock_import, mock_ami, mock_session, tmp_path):
929
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
930
+
931
+ key_path = tmp_path / "id_ed25519.pub"
932
+ key_path.write_text("ssh-ed25519 AAAA test@host")
933
+
934
+ runner = CliRunner()
935
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--dry-run", "--ebs-storage", "96"])
936
+ assert result.exit_code == 0
937
+ assert "96 GB gp3" in result.output
938
+ assert "/data" in result.output
939
+
940
+
941
+ @patch("aws_bootstrap.cli.boto3.Session")
942
+ @patch("aws_bootstrap.cli.get_latest_ami")
943
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
944
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
945
+ def test_launch_dry_run_with_ebs_volume_id(mock_sg, mock_import, mock_ami, mock_session, tmp_path):
946
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
947
+
948
+ key_path = tmp_path / "id_ed25519.pub"
949
+ key_path.write_text("ssh-ed25519 AAAA test@host")
950
+
951
+ runner = CliRunner()
952
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--dry-run", "--ebs-volume-id", "vol-abc"])
953
+ assert result.exit_code == 0
954
+ assert "vol-abc" in result.output
955
+ assert "/data" in result.output
956
+
957
+
958
+ @patch("aws_bootstrap.cli.mount_ebs_volume", return_value=True)
959
+ @patch("aws_bootstrap.cli.attach_ebs_volume")
960
+ @patch("aws_bootstrap.cli.create_ebs_volume", return_value="vol-new123")
961
+ @patch("aws_bootstrap.cli.add_ssh_host", return_value="aws-gpu1")
962
+ @patch("aws_bootstrap.cli.run_remote_setup", return_value=True)
963
+ @patch("aws_bootstrap.cli.wait_for_ssh", return_value=True)
964
+ @patch("aws_bootstrap.cli.wait_instance_ready")
965
+ @patch("aws_bootstrap.cli.launch_instance")
966
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
967
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
968
+ @patch("aws_bootstrap.cli.get_latest_ami")
969
+ @patch("aws_bootstrap.cli.boto3.Session")
970
+ def test_launch_with_ebs_storage_full_flow(
971
+ mock_session,
972
+ mock_ami,
973
+ mock_import,
974
+ mock_sg,
975
+ mock_launch,
976
+ mock_wait,
977
+ mock_ssh,
978
+ mock_setup,
979
+ mock_add_ssh,
980
+ mock_create_ebs,
981
+ mock_attach_ebs,
982
+ mock_mount_ebs,
983
+ tmp_path,
984
+ ):
985
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
986
+ mock_launch.return_value = {"InstanceId": "i-test123"}
987
+ mock_wait.return_value = {
988
+ "PublicIpAddress": "1.2.3.4",
989
+ "Placement": {"AvailabilityZone": "us-west-2a"},
990
+ }
991
+
992
+ key_path = tmp_path / "id_ed25519.pub"
993
+ key_path.write_text("ssh-ed25519 AAAA test@host")
994
+
995
+ runner = CliRunner()
996
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--ebs-storage", "96", "--no-setup"])
997
+ assert result.exit_code == 0
998
+ assert "vol-new123" in result.output
999
+ mock_create_ebs.assert_called_once()
1000
+ mock_attach_ebs.assert_called_once()
1001
+ mock_mount_ebs.assert_called_once()
1002
+ # Verify format_volume=True for new volumes
1003
+ assert mock_mount_ebs.call_args[1]["format_volume"] is True
1004
+
1005
+
1006
+ @patch("aws_bootstrap.cli.mount_ebs_volume", return_value=True)
1007
+ @patch("aws_bootstrap.cli.attach_ebs_volume")
1008
+ @patch("aws_bootstrap.cli.validate_ebs_volume")
1009
+ @patch("aws_bootstrap.cli.add_ssh_host", return_value="aws-gpu1")
1010
+ @patch("aws_bootstrap.cli.run_remote_setup", return_value=True)
1011
+ @patch("aws_bootstrap.cli.wait_for_ssh", return_value=True)
1012
+ @patch("aws_bootstrap.cli.wait_instance_ready")
1013
+ @patch("aws_bootstrap.cli.launch_instance")
1014
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
1015
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
1016
+ @patch("aws_bootstrap.cli.get_latest_ami")
1017
+ @patch("aws_bootstrap.cli.boto3.Session")
1018
+ def test_launch_with_ebs_volume_id_full_flow(
1019
+ mock_session,
1020
+ mock_ami,
1021
+ mock_import,
1022
+ mock_sg,
1023
+ mock_launch,
1024
+ mock_wait,
1025
+ mock_ssh,
1026
+ mock_setup,
1027
+ mock_add_ssh,
1028
+ mock_validate,
1029
+ mock_attach_ebs,
1030
+ mock_mount_ebs,
1031
+ tmp_path,
1032
+ ):
1033
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
1034
+ mock_launch.return_value = {"InstanceId": "i-test123"}
1035
+ mock_wait.return_value = {
1036
+ "PublicIpAddress": "1.2.3.4",
1037
+ "Placement": {"AvailabilityZone": "us-west-2a"},
1038
+ }
1039
+ mock_validate.return_value = {"VolumeId": "vol-existing", "Size": 200}
1040
+
1041
+ key_path = tmp_path / "id_ed25519.pub"
1042
+ key_path.write_text("ssh-ed25519 AAAA test@host")
1043
+
1044
+ runner = CliRunner()
1045
+ result = runner.invoke(
1046
+ main, ["launch", "--key-path", str(key_path), "--ebs-volume-id", "vol-existing", "--no-setup"]
1047
+ )
1048
+ assert result.exit_code == 0
1049
+ mock_validate.assert_called_once()
1050
+ mock_attach_ebs.assert_called_once()
1051
+ mock_mount_ebs.assert_called_once()
1052
+ # Verify format_volume=False for existing volumes
1053
+ assert mock_mount_ebs.call_args[1]["format_volume"] is False
1054
+
1055
+
1056
+ @patch("aws_bootstrap.cli.mount_ebs_volume", return_value=False)
1057
+ @patch("aws_bootstrap.cli.attach_ebs_volume")
1058
+ @patch("aws_bootstrap.cli.create_ebs_volume", return_value="vol-new123")
1059
+ @patch("aws_bootstrap.cli.add_ssh_host", return_value="aws-gpu1")
1060
+ @patch("aws_bootstrap.cli.wait_for_ssh", return_value=True)
1061
+ @patch("aws_bootstrap.cli.wait_instance_ready")
1062
+ @patch("aws_bootstrap.cli.launch_instance")
1063
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
1064
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
1065
+ @patch("aws_bootstrap.cli.get_latest_ami")
1066
+ @patch("aws_bootstrap.cli.boto3.Session")
1067
+ def test_launch_ebs_mount_failure_warns(
1068
+ mock_session,
1069
+ mock_ami,
1070
+ mock_import,
1071
+ mock_sg,
1072
+ mock_launch,
1073
+ mock_wait,
1074
+ mock_ssh,
1075
+ mock_add_ssh,
1076
+ mock_create_ebs,
1077
+ mock_attach_ebs,
1078
+ mock_mount_ebs,
1079
+ tmp_path,
1080
+ ):
1081
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
1082
+ mock_launch.return_value = {"InstanceId": "i-test123"}
1083
+ mock_wait.return_value = {
1084
+ "PublicIpAddress": "1.2.3.4",
1085
+ "Placement": {"AvailabilityZone": "us-west-2a"},
1086
+ }
1087
+
1088
+ key_path = tmp_path / "id_ed25519.pub"
1089
+ key_path.write_text("ssh-ed25519 AAAA test@host")
1090
+
1091
+ runner = CliRunner()
1092
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--ebs-storage", "96", "--no-setup"])
1093
+ # Should succeed despite mount failure (just a warning)
1094
+ assert result.exit_code == 0
1095
+ assert "WARNING" in result.output or "Failed to mount" in result.output
1096
+
1097
+
1098
+ def test_terminate_help_shows_keep_ebs():
1099
+ runner = CliRunner()
1100
+ result = runner.invoke(main, ["terminate", "--help"])
1101
+ assert result.exit_code == 0
1102
+ assert "--keep-ebs" in result.output
1103
+
1104
+
1105
+ @patch("aws_bootstrap.cli.delete_ebs_volume")
1106
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance")
1107
+ @patch("aws_bootstrap.cli.remove_ssh_host", return_value=None)
1108
+ @patch("aws_bootstrap.cli.boto3.Session")
1109
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1110
+ @patch("aws_bootstrap.cli.terminate_tagged_instances")
1111
+ def test_terminate_deletes_ebs_by_default(
1112
+ mock_terminate, mock_find, mock_session, mock_remove_ssh, mock_find_ebs, mock_delete_ebs
1113
+ ):
1114
+ mock_find.return_value = [
1115
+ {
1116
+ "InstanceId": "i-abc123",
1117
+ "Name": "test",
1118
+ "State": "running",
1119
+ "InstanceType": "g4dn.xlarge",
1120
+ "PublicIp": "1.2.3.4",
1121
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1122
+ }
1123
+ ]
1124
+ mock_terminate.return_value = [
1125
+ {
1126
+ "InstanceId": "i-abc123",
1127
+ "PreviousState": {"Name": "running"},
1128
+ "CurrentState": {"Name": "shutting-down"},
1129
+ }
1130
+ ]
1131
+ mock_find_ebs.return_value = [{"VolumeId": "vol-data1", "Size": 96, "Device": "/dev/sdf", "State": "in-use"}]
1132
+
1133
+ # Mock the ec2 client's get_waiter for volume_available
1134
+ mock_ec2 = mock_session.return_value.client.return_value
1135
+ mock_waiter = MagicMock()
1136
+ mock_ec2.get_waiter.return_value = mock_waiter
1137
+
1138
+ runner = CliRunner()
1139
+ result = runner.invoke(main, ["terminate", "--yes"])
1140
+ assert result.exit_code == 0
1141
+ mock_delete_ebs.assert_called_once_with(mock_ec2, "vol-data1")
1142
+
1143
+
1144
+ @patch("aws_bootstrap.cli.delete_ebs_volume")
1145
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance")
1146
+ @patch("aws_bootstrap.cli.remove_ssh_host", return_value=None)
1147
+ @patch("aws_bootstrap.cli.boto3.Session")
1148
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1149
+ @patch("aws_bootstrap.cli.terminate_tagged_instances")
1150
+ def test_terminate_keep_ebs_preserves(
1151
+ mock_terminate, mock_find, mock_session, mock_remove_ssh, mock_find_ebs, mock_delete_ebs
1152
+ ):
1153
+ mock_find.return_value = [
1154
+ {
1155
+ "InstanceId": "i-abc123",
1156
+ "Name": "test",
1157
+ "State": "running",
1158
+ "InstanceType": "g4dn.xlarge",
1159
+ "PublicIp": "1.2.3.4",
1160
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1161
+ }
1162
+ ]
1163
+ mock_terminate.return_value = [
1164
+ {
1165
+ "InstanceId": "i-abc123",
1166
+ "PreviousState": {"Name": "running"},
1167
+ "CurrentState": {"Name": "shutting-down"},
1168
+ }
1169
+ ]
1170
+ mock_find_ebs.return_value = [{"VolumeId": "vol-data1", "Size": 96, "Device": "/dev/sdf", "State": "in-use"}]
1171
+
1172
+ runner = CliRunner()
1173
+ result = runner.invoke(main, ["terminate", "--yes", "--keep-ebs"])
1174
+ assert result.exit_code == 0
1175
+ assert "Preserving EBS volume: vol-data1" in result.output
1176
+ assert "aws-bootstrap launch --ebs-volume-id vol-data1" in result.output
1177
+ mock_delete_ebs.assert_not_called()
1178
+
1179
+
1180
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance")
1181
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1182
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1183
+ @patch("aws_bootstrap.cli.boto3.Session")
1184
+ @patch("aws_bootstrap.cli.get_spot_price")
1185
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1186
+ def test_status_shows_ebs_volumes(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1187
+ mock_find.return_value = [
1188
+ {
1189
+ "InstanceId": "i-abc123",
1190
+ "Name": "aws-bootstrap-g4dn.xlarge",
1191
+ "State": "running",
1192
+ "InstanceType": "g4dn.xlarge",
1193
+ "PublicIp": "1.2.3.4",
1194
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1195
+ "Lifecycle": "spot",
1196
+ "AvailabilityZone": "us-west-2a",
1197
+ }
1198
+ ]
1199
+ mock_spot_price.return_value = 0.15
1200
+ mock_ebs.return_value = [{"VolumeId": "vol-data1", "Size": 96, "Device": "/dev/sdf", "State": "in-use"}]
1201
+
1202
+ runner = CliRunner()
1203
+ result = runner.invoke(main, ["status"])
1204
+ assert result.exit_code == 0
1205
+ assert "vol-data1" in result.output
1206
+ assert "96 GB" in result.output
1207
+ assert "/data" in result.output
1208
+
1209
+
1210
+ # ---------------------------------------------------------------------------
1211
+ # cleanup subcommand
1212
+ # ---------------------------------------------------------------------------
1213
+
1214
+
1215
+ def test_cleanup_help():
1216
+ runner = CliRunner()
1217
+ result = runner.invoke(main, ["cleanup", "--help"])
1218
+ assert result.exit_code == 0
1219
+ assert "--dry-run" in result.output
1220
+ assert "--yes" in result.output
1221
+ assert "--region" in result.output
1222
+ assert "--profile" in result.output
1223
+
1224
+
1225
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1226
+ @patch("aws_bootstrap.cli.boto3.Session")
1227
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1228
+ def test_cleanup_no_stale(mock_find, mock_session, mock_stale):
1229
+ runner = CliRunner()
1230
+ result = runner.invoke(main, ["cleanup"])
1231
+ assert result.exit_code == 0
1232
+ assert "No stale" in result.output
1233
+
1234
+
1235
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1236
+ @patch("aws_bootstrap.cli.boto3.Session")
1237
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1238
+ def test_cleanup_dry_run(mock_find, mock_session, mock_stale):
1239
+ runner = CliRunner()
1240
+ result = runner.invoke(main, ["cleanup", "--dry-run"])
1241
+ assert result.exit_code == 0
1242
+ assert "Would remove" in result.output
1243
+ assert "aws-gpu1" in result.output
1244
+ assert "i-dead1234" in result.output
1245
+
1246
+
1247
+ @patch("aws_bootstrap.cli.cleanup_stale_ssh_hosts")
1248
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1249
+ @patch("aws_bootstrap.cli.boto3.Session")
1250
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1251
+ def test_cleanup_with_yes(mock_find, mock_session, mock_stale, mock_cleanup):
1252
+ mock_cleanup.return_value = [CleanupResult(instance_id="i-dead1234", alias="aws-gpu1", removed=True)]
1253
+ runner = CliRunner()
1254
+ result = runner.invoke(main, ["cleanup", "--yes"])
1255
+ assert result.exit_code == 0
1256
+ assert "Removed aws-gpu1" in result.output
1257
+ assert "Cleaned up 1" in result.output
1258
+ mock_cleanup.assert_called_once()
1259
+
1260
+
1261
+ # ---------------------------------------------------------------------------
1262
+ # --output structured format tests
1263
+ # ---------------------------------------------------------------------------
1264
+
1265
+
1266
+ def test_help_shows_output_option():
1267
+ runner = CliRunner()
1268
+ result = runner.invoke(main, ["--help"])
1269
+ assert result.exit_code == 0
1270
+ assert "--output" in result.output
1271
+ assert "-o" in result.output
1272
+
1273
+
1274
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance", return_value=[])
1275
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1276
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1277
+ @patch("aws_bootstrap.cli.boto3.Session")
1278
+ @patch("aws_bootstrap.cli.get_spot_price")
1279
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1280
+ def test_status_output_json(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1281
+ mock_find.return_value = [
1282
+ {
1283
+ "InstanceId": "i-abc123",
1284
+ "Name": "aws-bootstrap-g4dn.xlarge",
1285
+ "State": "running",
1286
+ "InstanceType": "g4dn.xlarge",
1287
+ "PublicIp": "1.2.3.4",
1288
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1289
+ "Lifecycle": "spot",
1290
+ "AvailabilityZone": "us-west-2a",
1291
+ }
1292
+ ]
1293
+ mock_spot_price.return_value = 0.1578
1294
+ runner = CliRunner()
1295
+ result = runner.invoke(main, ["-o", "json", "status"])
1296
+ assert result.exit_code == 0
1297
+ data = json.loads(result.output)
1298
+ assert "instances" in data
1299
+ assert len(data["instances"]) == 1
1300
+ inst = data["instances"][0]
1301
+ assert inst["instance_id"] == "i-abc123"
1302
+ assert inst["state"] == "running"
1303
+ assert inst["instance_type"] == "g4dn.xlarge"
1304
+ assert inst["public_ip"] == "1.2.3.4"
1305
+ assert inst["lifecycle"] == "spot"
1306
+ assert inst["spot_price_per_hour"] == 0.1578
1307
+ assert "uptime_seconds" in inst
1308
+ assert "estimated_cost" in inst
1309
+ # No ANSI or progress text in structured output
1310
+ assert "\x1b[" not in result.output
1311
+ assert "Found" not in result.output
1312
+
1313
+
1314
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance", return_value=[])
1315
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1316
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1317
+ @patch("aws_bootstrap.cli.boto3.Session")
1318
+ @patch("aws_bootstrap.cli.get_spot_price")
1319
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1320
+ def test_status_output_yaml(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1321
+ mock_find.return_value = [
1322
+ {
1323
+ "InstanceId": "i-abc123",
1324
+ "Name": "aws-bootstrap-g4dn.xlarge",
1325
+ "State": "running",
1326
+ "InstanceType": "g4dn.xlarge",
1327
+ "PublicIp": "1.2.3.4",
1328
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1329
+ "Lifecycle": "spot",
1330
+ "AvailabilityZone": "us-west-2a",
1331
+ }
1332
+ ]
1333
+ mock_spot_price.return_value = 0.15
1334
+ runner = CliRunner()
1335
+ result = runner.invoke(main, ["-o", "yaml", "status"])
1336
+ assert result.exit_code == 0
1337
+ data = yaml.safe_load(result.output)
1338
+ assert "instances" in data
1339
+ assert data["instances"][0]["instance_id"] == "i-abc123"
1340
+
1341
+
1342
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance", return_value=[])
1343
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1344
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1345
+ @patch("aws_bootstrap.cli.boto3.Session")
1346
+ @patch("aws_bootstrap.cli.get_spot_price")
1347
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1348
+ def test_status_output_table(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1349
+ mock_find.return_value = [
1350
+ {
1351
+ "InstanceId": "i-abc123",
1352
+ "Name": "aws-bootstrap-g4dn.xlarge",
1353
+ "State": "running",
1354
+ "InstanceType": "g4dn.xlarge",
1355
+ "PublicIp": "1.2.3.4",
1356
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1357
+ "Lifecycle": "spot",
1358
+ "AvailabilityZone": "us-west-2a",
1359
+ }
1360
+ ]
1361
+ mock_spot_price.return_value = 0.15
1362
+ runner = CliRunner()
1363
+ result = runner.invoke(main, ["-o", "table", "status"])
1364
+ assert result.exit_code == 0
1365
+ assert "Instance ID" in result.output
1366
+ assert "i-abc123" in result.output
1367
+
1368
+
1369
+ @patch("aws_bootstrap.cli.boto3.Session")
1370
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1371
+ def test_status_no_instances_json(mock_find, mock_session):
1372
+ mock_find.return_value = []
1373
+ runner = CliRunner()
1374
+ result = runner.invoke(main, ["-o", "json", "status"])
1375
+ assert result.exit_code == 0
1376
+ data = json.loads(result.output)
1377
+ assert data == {"instances": []}
1378
+
1379
+
1380
+ @patch("aws_bootstrap.cli.boto3.Session")
1381
+ @patch("aws_bootstrap.cli.get_latest_ami")
1382
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
1383
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
1384
+ def test_launch_output_json_dry_run(mock_sg, mock_import, mock_ami, mock_session, tmp_path):
1385
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
1386
+
1387
+ key_path = tmp_path / "id_ed25519.pub"
1388
+ key_path.write_text("ssh-ed25519 AAAA test@host")
1389
+
1390
+ runner = CliRunner()
1391
+ result = runner.invoke(main, ["-o", "json", "launch", "--key-path", str(key_path), "--dry-run"])
1392
+ assert result.exit_code == 0
1393
+ data = json.loads(result.output)
1394
+ assert data["dry_run"] is True
1395
+ assert data["instance_type"] == "g4dn.xlarge"
1396
+ assert data["ami_id"] == "ami-123"
1397
+ assert data["pricing"] == "spot"
1398
+ assert data["region"] == "us-west-2"
1399
+
1400
+
1401
+ @patch("aws_bootstrap.cli.remove_ssh_host", return_value="aws-gpu1")
1402
+ @patch("aws_bootstrap.cli.boto3.Session")
1403
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1404
+ @patch("aws_bootstrap.cli.terminate_tagged_instances")
1405
+ def test_terminate_output_json(mock_terminate, mock_find, mock_session, mock_remove_ssh):
1406
+ mock_find.return_value = [
1407
+ {
1408
+ "InstanceId": "i-abc123",
1409
+ "Name": "test",
1410
+ "State": "running",
1411
+ "InstanceType": "g4dn.xlarge",
1412
+ "PublicIp": "1.2.3.4",
1413
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1414
+ }
1415
+ ]
1416
+ mock_terminate.return_value = [
1417
+ {
1418
+ "InstanceId": "i-abc123",
1419
+ "PreviousState": {"Name": "running"},
1420
+ "CurrentState": {"Name": "shutting-down"},
1421
+ }
1422
+ ]
1423
+ runner = CliRunner()
1424
+ result = runner.invoke(main, ["-o", "json", "terminate", "--yes"])
1425
+ assert result.exit_code == 0
1426
+ data = json.loads(result.output)
1427
+ assert "terminated" in data
1428
+ assert len(data["terminated"]) == 1
1429
+ assert data["terminated"][0]["instance_id"] == "i-abc123"
1430
+ assert data["terminated"][0]["previous_state"] == "running"
1431
+ assert data["terminated"][0]["current_state"] == "shutting-down"
1432
+ assert data["terminated"][0]["ssh_alias_removed"] == "aws-gpu1"
1433
+
1434
+
1435
+ @patch("aws_bootstrap.cli.cleanup_stale_ssh_hosts")
1436
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1437
+ @patch("aws_bootstrap.cli.boto3.Session")
1438
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1439
+ def test_cleanup_output_json(mock_find, mock_session, mock_stale, mock_cleanup):
1440
+ mock_cleanup.return_value = [CleanupResult(instance_id="i-dead1234", alias="aws-gpu1", removed=True)]
1441
+ runner = CliRunner()
1442
+ result = runner.invoke(main, ["-o", "json", "cleanup", "--yes"])
1443
+ assert result.exit_code == 0
1444
+ data = json.loads(result.output)
1445
+ assert "cleaned" in data
1446
+ assert len(data["cleaned"]) == 1
1447
+ assert data["cleaned"][0]["instance_id"] == "i-dead1234"
1448
+ assert data["cleaned"][0]["alias"] == "aws-gpu1"
1449
+ assert data["cleaned"][0]["removed"] is True
1450
+
1451
+
1452
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1453
+ @patch("aws_bootstrap.cli.boto3.Session")
1454
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1455
+ def test_cleanup_dry_run_json(mock_find, mock_session, mock_stale):
1456
+ runner = CliRunner()
1457
+ result = runner.invoke(main, ["-o", "json", "cleanup", "--dry-run"])
1458
+ assert result.exit_code == 0
1459
+ data = json.loads(result.output)
1460
+ assert data["dry_run"] is True
1461
+ assert "stale" in data
1462
+ assert data["stale"][0]["alias"] == "aws-gpu1"
1463
+
1464
+
1465
+ @patch("aws_bootstrap.cli.boto3.Session")
1466
+ @patch("aws_bootstrap.cli.list_instance_types")
1467
+ def test_list_instance_types_json(mock_list, mock_session):
1468
+ mock_list.return_value = [
1469
+ {
1470
+ "InstanceType": "g4dn.xlarge",
1471
+ "VCpuCount": 4,
1472
+ "MemoryMiB": 16384,
1473
+ "GpuSummary": "1x T4 (16384 MiB)",
1474
+ },
1475
+ ]
1476
+ runner = CliRunner()
1477
+ result = runner.invoke(main, ["-o", "json", "list", "instance-types"])
1478
+ assert result.exit_code == 0
1479
+ data = json.loads(result.output)
1480
+ assert isinstance(data, list)
1481
+ assert data[0]["instance_type"] == "g4dn.xlarge"
1482
+ assert data[0]["vcpus"] == 4
1483
+ assert data[0]["memory_mib"] == 16384
1484
+ assert data[0]["gpu"] == "1x T4 (16384 MiB)"
1485
+
1486
+
1487
+ @patch("aws_bootstrap.cli.boto3.Session")
1488
+ @patch("aws_bootstrap.cli.list_amis")
1489
+ def test_list_amis_json(mock_list, mock_session):
1490
+ mock_list.return_value = [
1491
+ {
1492
+ "ImageId": "ami-abc123",
1493
+ "Name": "Deep Learning AMI v42",
1494
+ "CreationDate": "2025-06-01T00:00:00Z",
1495
+ "Architecture": "x86_64",
1496
+ },
1497
+ ]
1498
+ runner = CliRunner()
1499
+ result = runner.invoke(main, ["-o", "json", "list", "amis"])
1500
+ assert result.exit_code == 0
1501
+ data = json.loads(result.output)
1502
+ assert isinstance(data, list)
1503
+ assert data[0]["image_id"] == "ami-abc123"
1504
+ assert data[0]["name"] == "Deep Learning AMI v42"
1505
+ assert data[0]["creation_date"] == "2025-06-01"
1506
+
1507
+
1508
+ @patch("aws_bootstrap.cli.boto3.Session")
1509
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1510
+ def test_terminate_json_requires_yes(mock_find, mock_session):
1511
+ """Structured output without --yes should error."""
1512
+ mock_find.return_value = [
1513
+ {
1514
+ "InstanceId": "i-abc123",
1515
+ "Name": "test",
1516
+ "State": "running",
1517
+ "InstanceType": "g4dn.xlarge",
1518
+ "PublicIp": "1.2.3.4",
1519
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1520
+ }
1521
+ ]
1522
+ runner = CliRunner()
1523
+ result = runner.invoke(main, ["-o", "json", "terminate"])
1524
+ assert result.exit_code != 0
1525
+ assert "--yes is required" in result.output
1526
+
1527
+
1528
+ @patch("aws_bootstrap.cli.boto3.Session")
1529
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1530
+ def test_terminate_no_instances_json(mock_find, mock_session):
1531
+ mock_find.return_value = []
1532
+ runner = CliRunner()
1533
+ result = runner.invoke(main, ["-o", "json", "terminate", "--yes"])
1534
+ assert result.exit_code == 0
1535
+ data = json.loads(result.output)
1536
+ assert data == {"terminated": []}