aws-bootstrap-g4dn 0.5.0__py3-none-any.whl → 0.6.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.
@@ -3,14 +3,14 @@
3
3
  from __future__ import annotations
4
4
  from datetime import UTC, datetime
5
5
  from pathlib import Path
6
- from unittest.mock import patch
6
+ from unittest.mock import MagicMock, patch
7
7
 
8
8
  import botocore.exceptions
9
9
  from click.testing import CliRunner
10
10
 
11
11
  from aws_bootstrap.cli import main
12
12
  from aws_bootstrap.gpu import GpuInfo
13
- from aws_bootstrap.ssh import SSHHostDetails
13
+ from aws_bootstrap.ssh import CleanupResult, SSHHostDetails
14
14
 
15
15
 
16
16
  def test_help():
@@ -347,7 +347,10 @@ def test_launch_output_shows_ssh_alias(
347
347
  ):
348
348
  mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
349
349
  mock_launch.return_value = {"InstanceId": "i-test123"}
350
- mock_wait.return_value = {"PublicIpAddress": "1.2.3.4"}
350
+ mock_wait.return_value = {
351
+ "PublicIpAddress": "1.2.3.4",
352
+ "Placement": {"AvailabilityZone": "us-west-2a"},
353
+ }
351
354
 
352
355
  key_path = tmp_path / "id_ed25519.pub"
353
356
  key_path.write_text("ssh-ed25519 AAAA test@host")
@@ -799,7 +802,10 @@ def test_launch_python_version_passed_to_setup(
799
802
  ):
800
803
  mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
801
804
  mock_launch.return_value = {"InstanceId": "i-test123"}
802
- mock_wait.return_value = {"PublicIpAddress": "1.2.3.4"}
805
+ mock_wait.return_value = {
806
+ "PublicIpAddress": "1.2.3.4",
807
+ "Placement": {"AvailabilityZone": "us-west-2a"},
808
+ }
803
809
 
804
810
  key_path = tmp_path / "id_ed25519.pub"
805
811
  key_path.write_text("ssh-ed25519 AAAA test@host")
@@ -886,3 +892,365 @@ def test_launch_dry_run_omits_ssh_port_when_default(mock_sg, mock_import, mock_a
886
892
  result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--dry-run"])
887
893
  assert result.exit_code == 0
888
894
  assert "SSH port" not in result.output
895
+
896
+
897
+ # ---------------------------------------------------------------------------
898
+ # EBS data volume tests
899
+ # ---------------------------------------------------------------------------
900
+
901
+
902
+ def test_launch_help_shows_ebs_options():
903
+ runner = CliRunner()
904
+ result = runner.invoke(main, ["launch", "--help"])
905
+ assert result.exit_code == 0
906
+ assert "--ebs-storage" in result.output
907
+ assert "--ebs-volume-id" in result.output
908
+
909
+
910
+ def test_launch_ebs_mutual_exclusivity(tmp_path):
911
+ key_path = tmp_path / "id_ed25519.pub"
912
+ key_path.write_text("ssh-ed25519 AAAA test@host")
913
+
914
+ runner = CliRunner()
915
+ result = runner.invoke(
916
+ main, ["launch", "--key-path", str(key_path), "--ebs-storage", "96", "--ebs-volume-id", "vol-abc"]
917
+ )
918
+ assert result.exit_code != 0
919
+ assert "mutually exclusive" in result.output
920
+
921
+
922
+ @patch("aws_bootstrap.cli.boto3.Session")
923
+ @patch("aws_bootstrap.cli.get_latest_ami")
924
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
925
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
926
+ def test_launch_dry_run_with_ebs_storage(mock_sg, mock_import, mock_ami, mock_session, tmp_path):
927
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
928
+
929
+ key_path = tmp_path / "id_ed25519.pub"
930
+ key_path.write_text("ssh-ed25519 AAAA test@host")
931
+
932
+ runner = CliRunner()
933
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--dry-run", "--ebs-storage", "96"])
934
+ assert result.exit_code == 0
935
+ assert "96 GB gp3" in result.output
936
+ assert "/data" in result.output
937
+
938
+
939
+ @patch("aws_bootstrap.cli.boto3.Session")
940
+ @patch("aws_bootstrap.cli.get_latest_ami")
941
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
942
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
943
+ def test_launch_dry_run_with_ebs_volume_id(mock_sg, mock_import, mock_ami, mock_session, tmp_path):
944
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
945
+
946
+ key_path = tmp_path / "id_ed25519.pub"
947
+ key_path.write_text("ssh-ed25519 AAAA test@host")
948
+
949
+ runner = CliRunner()
950
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--dry-run", "--ebs-volume-id", "vol-abc"])
951
+ assert result.exit_code == 0
952
+ assert "vol-abc" in result.output
953
+ assert "/data" in result.output
954
+
955
+
956
+ @patch("aws_bootstrap.cli.mount_ebs_volume", return_value=True)
957
+ @patch("aws_bootstrap.cli.attach_ebs_volume")
958
+ @patch("aws_bootstrap.cli.create_ebs_volume", return_value="vol-new123")
959
+ @patch("aws_bootstrap.cli.add_ssh_host", return_value="aws-gpu1")
960
+ @patch("aws_bootstrap.cli.run_remote_setup", return_value=True)
961
+ @patch("aws_bootstrap.cli.wait_for_ssh", return_value=True)
962
+ @patch("aws_bootstrap.cli.wait_instance_ready")
963
+ @patch("aws_bootstrap.cli.launch_instance")
964
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
965
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
966
+ @patch("aws_bootstrap.cli.get_latest_ami")
967
+ @patch("aws_bootstrap.cli.boto3.Session")
968
+ def test_launch_with_ebs_storage_full_flow(
969
+ mock_session,
970
+ mock_ami,
971
+ mock_import,
972
+ mock_sg,
973
+ mock_launch,
974
+ mock_wait,
975
+ mock_ssh,
976
+ mock_setup,
977
+ mock_add_ssh,
978
+ mock_create_ebs,
979
+ mock_attach_ebs,
980
+ mock_mount_ebs,
981
+ tmp_path,
982
+ ):
983
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
984
+ mock_launch.return_value = {"InstanceId": "i-test123"}
985
+ mock_wait.return_value = {
986
+ "PublicIpAddress": "1.2.3.4",
987
+ "Placement": {"AvailabilityZone": "us-west-2a"},
988
+ }
989
+
990
+ key_path = tmp_path / "id_ed25519.pub"
991
+ key_path.write_text("ssh-ed25519 AAAA test@host")
992
+
993
+ runner = CliRunner()
994
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--ebs-storage", "96", "--no-setup"])
995
+ assert result.exit_code == 0
996
+ assert "vol-new123" in result.output
997
+ mock_create_ebs.assert_called_once()
998
+ mock_attach_ebs.assert_called_once()
999
+ mock_mount_ebs.assert_called_once()
1000
+ # Verify format_volume=True for new volumes
1001
+ assert mock_mount_ebs.call_args[1]["format_volume"] is True
1002
+
1003
+
1004
+ @patch("aws_bootstrap.cli.mount_ebs_volume", return_value=True)
1005
+ @patch("aws_bootstrap.cli.attach_ebs_volume")
1006
+ @patch("aws_bootstrap.cli.validate_ebs_volume")
1007
+ @patch("aws_bootstrap.cli.add_ssh_host", return_value="aws-gpu1")
1008
+ @patch("aws_bootstrap.cli.run_remote_setup", return_value=True)
1009
+ @patch("aws_bootstrap.cli.wait_for_ssh", return_value=True)
1010
+ @patch("aws_bootstrap.cli.wait_instance_ready")
1011
+ @patch("aws_bootstrap.cli.launch_instance")
1012
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
1013
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
1014
+ @patch("aws_bootstrap.cli.get_latest_ami")
1015
+ @patch("aws_bootstrap.cli.boto3.Session")
1016
+ def test_launch_with_ebs_volume_id_full_flow(
1017
+ mock_session,
1018
+ mock_ami,
1019
+ mock_import,
1020
+ mock_sg,
1021
+ mock_launch,
1022
+ mock_wait,
1023
+ mock_ssh,
1024
+ mock_setup,
1025
+ mock_add_ssh,
1026
+ mock_validate,
1027
+ mock_attach_ebs,
1028
+ mock_mount_ebs,
1029
+ tmp_path,
1030
+ ):
1031
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
1032
+ mock_launch.return_value = {"InstanceId": "i-test123"}
1033
+ mock_wait.return_value = {
1034
+ "PublicIpAddress": "1.2.3.4",
1035
+ "Placement": {"AvailabilityZone": "us-west-2a"},
1036
+ }
1037
+ mock_validate.return_value = {"VolumeId": "vol-existing", "Size": 200}
1038
+
1039
+ key_path = tmp_path / "id_ed25519.pub"
1040
+ key_path.write_text("ssh-ed25519 AAAA test@host")
1041
+
1042
+ runner = CliRunner()
1043
+ result = runner.invoke(
1044
+ main, ["launch", "--key-path", str(key_path), "--ebs-volume-id", "vol-existing", "--no-setup"]
1045
+ )
1046
+ assert result.exit_code == 0
1047
+ mock_validate.assert_called_once()
1048
+ mock_attach_ebs.assert_called_once()
1049
+ mock_mount_ebs.assert_called_once()
1050
+ # Verify format_volume=False for existing volumes
1051
+ assert mock_mount_ebs.call_args[1]["format_volume"] is False
1052
+
1053
+
1054
+ @patch("aws_bootstrap.cli.mount_ebs_volume", return_value=False)
1055
+ @patch("aws_bootstrap.cli.attach_ebs_volume")
1056
+ @patch("aws_bootstrap.cli.create_ebs_volume", return_value="vol-new123")
1057
+ @patch("aws_bootstrap.cli.add_ssh_host", return_value="aws-gpu1")
1058
+ @patch("aws_bootstrap.cli.wait_for_ssh", return_value=True)
1059
+ @patch("aws_bootstrap.cli.wait_instance_ready")
1060
+ @patch("aws_bootstrap.cli.launch_instance")
1061
+ @patch("aws_bootstrap.cli.ensure_security_group", return_value="sg-123")
1062
+ @patch("aws_bootstrap.cli.import_key_pair", return_value="aws-bootstrap-key")
1063
+ @patch("aws_bootstrap.cli.get_latest_ami")
1064
+ @patch("aws_bootstrap.cli.boto3.Session")
1065
+ def test_launch_ebs_mount_failure_warns(
1066
+ mock_session,
1067
+ mock_ami,
1068
+ mock_import,
1069
+ mock_sg,
1070
+ mock_launch,
1071
+ mock_wait,
1072
+ mock_ssh,
1073
+ mock_add_ssh,
1074
+ mock_create_ebs,
1075
+ mock_attach_ebs,
1076
+ mock_mount_ebs,
1077
+ tmp_path,
1078
+ ):
1079
+ mock_ami.return_value = {"ImageId": "ami-123", "Name": "TestAMI"}
1080
+ mock_launch.return_value = {"InstanceId": "i-test123"}
1081
+ mock_wait.return_value = {
1082
+ "PublicIpAddress": "1.2.3.4",
1083
+ "Placement": {"AvailabilityZone": "us-west-2a"},
1084
+ }
1085
+
1086
+ key_path = tmp_path / "id_ed25519.pub"
1087
+ key_path.write_text("ssh-ed25519 AAAA test@host")
1088
+
1089
+ runner = CliRunner()
1090
+ result = runner.invoke(main, ["launch", "--key-path", str(key_path), "--ebs-storage", "96", "--no-setup"])
1091
+ # Should succeed despite mount failure (just a warning)
1092
+ assert result.exit_code == 0
1093
+ assert "WARNING" in result.output or "Failed to mount" in result.output
1094
+
1095
+
1096
+ def test_terminate_help_shows_keep_ebs():
1097
+ runner = CliRunner()
1098
+ result = runner.invoke(main, ["terminate", "--help"])
1099
+ assert result.exit_code == 0
1100
+ assert "--keep-ebs" in result.output
1101
+
1102
+
1103
+ @patch("aws_bootstrap.cli.delete_ebs_volume")
1104
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance")
1105
+ @patch("aws_bootstrap.cli.remove_ssh_host", return_value=None)
1106
+ @patch("aws_bootstrap.cli.boto3.Session")
1107
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1108
+ @patch("aws_bootstrap.cli.terminate_tagged_instances")
1109
+ def test_terminate_deletes_ebs_by_default(
1110
+ mock_terminate, mock_find, mock_session, mock_remove_ssh, mock_find_ebs, mock_delete_ebs
1111
+ ):
1112
+ mock_find.return_value = [
1113
+ {
1114
+ "InstanceId": "i-abc123",
1115
+ "Name": "test",
1116
+ "State": "running",
1117
+ "InstanceType": "g4dn.xlarge",
1118
+ "PublicIp": "1.2.3.4",
1119
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1120
+ }
1121
+ ]
1122
+ mock_terminate.return_value = [
1123
+ {
1124
+ "InstanceId": "i-abc123",
1125
+ "PreviousState": {"Name": "running"},
1126
+ "CurrentState": {"Name": "shutting-down"},
1127
+ }
1128
+ ]
1129
+ mock_find_ebs.return_value = [{"VolumeId": "vol-data1", "Size": 96, "Device": "/dev/sdf", "State": "in-use"}]
1130
+
1131
+ # Mock the ec2 client's get_waiter for volume_available
1132
+ mock_ec2 = mock_session.return_value.client.return_value
1133
+ mock_waiter = MagicMock()
1134
+ mock_ec2.get_waiter.return_value = mock_waiter
1135
+
1136
+ runner = CliRunner()
1137
+ result = runner.invoke(main, ["terminate", "--yes"])
1138
+ assert result.exit_code == 0
1139
+ mock_delete_ebs.assert_called_once_with(mock_ec2, "vol-data1")
1140
+
1141
+
1142
+ @patch("aws_bootstrap.cli.delete_ebs_volume")
1143
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance")
1144
+ @patch("aws_bootstrap.cli.remove_ssh_host", return_value=None)
1145
+ @patch("aws_bootstrap.cli.boto3.Session")
1146
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1147
+ @patch("aws_bootstrap.cli.terminate_tagged_instances")
1148
+ def test_terminate_keep_ebs_preserves(
1149
+ mock_terminate, mock_find, mock_session, mock_remove_ssh, mock_find_ebs, mock_delete_ebs
1150
+ ):
1151
+ mock_find.return_value = [
1152
+ {
1153
+ "InstanceId": "i-abc123",
1154
+ "Name": "test",
1155
+ "State": "running",
1156
+ "InstanceType": "g4dn.xlarge",
1157
+ "PublicIp": "1.2.3.4",
1158
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1159
+ }
1160
+ ]
1161
+ mock_terminate.return_value = [
1162
+ {
1163
+ "InstanceId": "i-abc123",
1164
+ "PreviousState": {"Name": "running"},
1165
+ "CurrentState": {"Name": "shutting-down"},
1166
+ }
1167
+ ]
1168
+ mock_find_ebs.return_value = [{"VolumeId": "vol-data1", "Size": 96, "Device": "/dev/sdf", "State": "in-use"}]
1169
+
1170
+ runner = CliRunner()
1171
+ result = runner.invoke(main, ["terminate", "--yes", "--keep-ebs"])
1172
+ assert result.exit_code == 0
1173
+ assert "Preserving EBS volume: vol-data1" in result.output
1174
+ assert "aws-bootstrap launch --ebs-volume-id vol-data1" in result.output
1175
+ mock_delete_ebs.assert_not_called()
1176
+
1177
+
1178
+ @patch("aws_bootstrap.cli.find_ebs_volumes_for_instance")
1179
+ @patch("aws_bootstrap.cli.get_ssh_host_details", return_value=None)
1180
+ @patch("aws_bootstrap.cli.list_ssh_hosts", return_value={})
1181
+ @patch("aws_bootstrap.cli.boto3.Session")
1182
+ @patch("aws_bootstrap.cli.get_spot_price")
1183
+ @patch("aws_bootstrap.cli.find_tagged_instances")
1184
+ def test_status_shows_ebs_volumes(mock_find, mock_spot_price, mock_session, mock_ssh_hosts, mock_details, mock_ebs):
1185
+ mock_find.return_value = [
1186
+ {
1187
+ "InstanceId": "i-abc123",
1188
+ "Name": "aws-bootstrap-g4dn.xlarge",
1189
+ "State": "running",
1190
+ "InstanceType": "g4dn.xlarge",
1191
+ "PublicIp": "1.2.3.4",
1192
+ "LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
1193
+ "Lifecycle": "spot",
1194
+ "AvailabilityZone": "us-west-2a",
1195
+ }
1196
+ ]
1197
+ mock_spot_price.return_value = 0.15
1198
+ mock_ebs.return_value = [{"VolumeId": "vol-data1", "Size": 96, "Device": "/dev/sdf", "State": "in-use"}]
1199
+
1200
+ runner = CliRunner()
1201
+ result = runner.invoke(main, ["status"])
1202
+ assert result.exit_code == 0
1203
+ assert "vol-data1" in result.output
1204
+ assert "96 GB" in result.output
1205
+ assert "/data" in result.output
1206
+
1207
+
1208
+ # ---------------------------------------------------------------------------
1209
+ # cleanup subcommand
1210
+ # ---------------------------------------------------------------------------
1211
+
1212
+
1213
+ def test_cleanup_help():
1214
+ runner = CliRunner()
1215
+ result = runner.invoke(main, ["cleanup", "--help"])
1216
+ assert result.exit_code == 0
1217
+ assert "--dry-run" in result.output
1218
+ assert "--yes" in result.output
1219
+ assert "--region" in result.output
1220
+ assert "--profile" in result.output
1221
+
1222
+
1223
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[])
1224
+ @patch("aws_bootstrap.cli.boto3.Session")
1225
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1226
+ def test_cleanup_no_stale(mock_find, mock_session, mock_stale):
1227
+ runner = CliRunner()
1228
+ result = runner.invoke(main, ["cleanup"])
1229
+ assert result.exit_code == 0
1230
+ assert "No stale" in result.output
1231
+
1232
+
1233
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1234
+ @patch("aws_bootstrap.cli.boto3.Session")
1235
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1236
+ def test_cleanup_dry_run(mock_find, mock_session, mock_stale):
1237
+ runner = CliRunner()
1238
+ result = runner.invoke(main, ["cleanup", "--dry-run"])
1239
+ assert result.exit_code == 0
1240
+ assert "Would remove" in result.output
1241
+ assert "aws-gpu1" in result.output
1242
+ assert "i-dead1234" in result.output
1243
+
1244
+
1245
+ @patch("aws_bootstrap.cli.cleanup_stale_ssh_hosts")
1246
+ @patch("aws_bootstrap.cli.find_stale_ssh_hosts", return_value=[("i-dead1234", "aws-gpu1")])
1247
+ @patch("aws_bootstrap.cli.boto3.Session")
1248
+ @patch("aws_bootstrap.cli.find_tagged_instances", return_value=[])
1249
+ def test_cleanup_with_yes(mock_find, mock_session, mock_stale, mock_cleanup):
1250
+ mock_cleanup.return_value = [CleanupResult(instance_id="i-dead1234", alias="aws-gpu1", removed=True)]
1251
+ runner = CliRunner()
1252
+ result = runner.invoke(main, ["cleanup", "--yes"])
1253
+ assert result.exit_code == 0
1254
+ assert "Removed aws-gpu1" in result.output
1255
+ assert "Cleaned up 1" in result.output
1256
+ mock_cleanup.assert_called_once()
@@ -20,6 +20,12 @@ def test_defaults():
20
20
  assert config.dry_run is False
21
21
 
22
22
 
23
+ def test_ebs_fields_default_none():
24
+ config = LaunchConfig()
25
+ assert config.ebs_storage is None
26
+ assert config.ebs_volume_id is None
27
+
28
+
23
29
  def test_overrides():
24
30
  config = LaunchConfig(
25
31
  instance_type="g5.xlarge",
@@ -33,3 +39,15 @@ def test_overrides():
33
39
  assert config.spot is False
34
40
  assert config.volume_size == 200
35
41
  assert config.key_path == Path("/tmp/test.pub")
42
+
43
+
44
+ def test_ebs_storage_override():
45
+ config = LaunchConfig(ebs_storage=96)
46
+ assert config.ebs_storage == 96
47
+ assert config.ebs_volume_id is None
48
+
49
+
50
+ def test_ebs_volume_id_override():
51
+ config = LaunchConfig(ebs_volume_id="vol-abc123")
52
+ assert config.ebs_volume_id == "vol-abc123"
53
+ assert config.ebs_storage is None
@@ -0,0 +1,245 @@
1
+ """Tests for EBS data volume operations in ec2.py."""
2
+
3
+ from __future__ import annotations
4
+ from unittest.mock import MagicMock
5
+
6
+ import botocore.exceptions
7
+ import pytest
8
+
9
+ from aws_bootstrap.ec2 import (
10
+ EBS_DEVICE_NAME,
11
+ CLIError,
12
+ attach_ebs_volume,
13
+ create_ebs_volume,
14
+ delete_ebs_volume,
15
+ detach_ebs_volume,
16
+ find_ebs_volumes_for_instance,
17
+ validate_ebs_volume,
18
+ )
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # create_ebs_volume
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def test_create_ebs_volume():
27
+ ec2 = MagicMock()
28
+ ec2.create_volume.return_value = {"VolumeId": "vol-abc123"}
29
+ waiter = MagicMock()
30
+ ec2.get_waiter.return_value = waiter
31
+
32
+ vol_id = create_ebs_volume(ec2, 96, "us-west-2a", "aws-bootstrap-g4dn", "i-test123")
33
+
34
+ assert vol_id == "vol-abc123"
35
+ ec2.create_volume.assert_called_once()
36
+ create_kwargs = ec2.create_volume.call_args[1]
37
+ assert create_kwargs["AvailabilityZone"] == "us-west-2a"
38
+ assert create_kwargs["Size"] == 96
39
+ assert create_kwargs["VolumeType"] == "gp3"
40
+
41
+ # Check tags
42
+ tags = create_kwargs["TagSpecifications"][0]["Tags"]
43
+ tag_dict = {t["Key"]: t["Value"] for t in tags}
44
+ assert tag_dict["created-by"] == "aws-bootstrap-g4dn"
45
+ assert tag_dict["Name"] == "aws-bootstrap-data-i-test123"
46
+ assert tag_dict["aws-bootstrap-instance"] == "i-test123"
47
+
48
+ ec2.get_waiter.assert_called_once_with("volume_available")
49
+ waiter.wait.assert_called_once()
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # validate_ebs_volume
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def test_validate_ebs_volume_valid():
58
+ ec2 = MagicMock()
59
+ ec2.describe_volumes.return_value = {
60
+ "Volumes": [
61
+ {
62
+ "VolumeId": "vol-abc123",
63
+ "State": "available",
64
+ "AvailabilityZone": "us-west-2a",
65
+ "Size": 100,
66
+ }
67
+ ]
68
+ }
69
+ vol = validate_ebs_volume(ec2, "vol-abc123", "us-west-2a")
70
+ assert vol["VolumeId"] == "vol-abc123"
71
+
72
+
73
+ def test_validate_ebs_volume_wrong_az():
74
+ ec2 = MagicMock()
75
+ ec2.describe_volumes.return_value = {
76
+ "Volumes": [
77
+ {
78
+ "VolumeId": "vol-abc123",
79
+ "State": "available",
80
+ "AvailabilityZone": "us-east-1a",
81
+ "Size": 100,
82
+ }
83
+ ]
84
+ }
85
+ with pytest.raises(CLIError, match="us-east-1a"):
86
+ validate_ebs_volume(ec2, "vol-abc123", "us-west-2a")
87
+
88
+
89
+ def test_validate_ebs_volume_in_use():
90
+ ec2 = MagicMock()
91
+ ec2.describe_volumes.return_value = {
92
+ "Volumes": [
93
+ {
94
+ "VolumeId": "vol-abc123",
95
+ "State": "in-use",
96
+ "AvailabilityZone": "us-west-2a",
97
+ "Size": 100,
98
+ }
99
+ ]
100
+ }
101
+ with pytest.raises(CLIError, match="in-use"):
102
+ validate_ebs_volume(ec2, "vol-abc123", "us-west-2a")
103
+
104
+
105
+ def test_validate_ebs_volume_not_found():
106
+ ec2 = MagicMock()
107
+ ec2.describe_volumes.side_effect = botocore.exceptions.ClientError(
108
+ {"Error": {"Code": "InvalidVolume.NotFound", "Message": "not found"}},
109
+ "DescribeVolumes",
110
+ )
111
+ with pytest.raises(CLIError, match="not found"):
112
+ validate_ebs_volume(ec2, "vol-notfound", "us-west-2a")
113
+
114
+
115
+ def test_validate_ebs_volume_empty_response():
116
+ ec2 = MagicMock()
117
+ ec2.describe_volumes.return_value = {"Volumes": []}
118
+ with pytest.raises(CLIError, match="not found"):
119
+ validate_ebs_volume(ec2, "vol-empty", "us-west-2a")
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # attach_ebs_volume
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def test_attach_ebs_volume():
128
+ ec2 = MagicMock()
129
+ waiter = MagicMock()
130
+ ec2.get_waiter.return_value = waiter
131
+
132
+ attach_ebs_volume(ec2, "vol-abc123", "i-test123")
133
+
134
+ ec2.attach_volume.assert_called_once_with(
135
+ VolumeId="vol-abc123",
136
+ InstanceId="i-test123",
137
+ Device=EBS_DEVICE_NAME,
138
+ )
139
+ ec2.get_waiter.assert_called_once_with("volume_in_use")
140
+ waiter.wait.assert_called_once()
141
+
142
+
143
+ def test_attach_ebs_volume_custom_device():
144
+ ec2 = MagicMock()
145
+ waiter = MagicMock()
146
+ ec2.get_waiter.return_value = waiter
147
+
148
+ attach_ebs_volume(ec2, "vol-abc123", "i-test123", device_name="/dev/sdg")
149
+
150
+ ec2.attach_volume.assert_called_once_with(
151
+ VolumeId="vol-abc123",
152
+ InstanceId="i-test123",
153
+ Device="/dev/sdg",
154
+ )
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # detach_ebs_volume
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def test_detach_ebs_volume():
163
+ ec2 = MagicMock()
164
+ waiter = MagicMock()
165
+ ec2.get_waiter.return_value = waiter
166
+
167
+ detach_ebs_volume(ec2, "vol-abc123")
168
+
169
+ ec2.detach_volume.assert_called_once_with(VolumeId="vol-abc123")
170
+ ec2.get_waiter.assert_called_once_with("volume_available")
171
+ waiter.wait.assert_called_once()
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # delete_ebs_volume
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def test_delete_ebs_volume():
180
+ ec2 = MagicMock()
181
+ delete_ebs_volume(ec2, "vol-abc123")
182
+ ec2.delete_volume.assert_called_once_with(VolumeId="vol-abc123")
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # find_ebs_volumes_for_instance
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def test_find_ebs_volumes_for_instance():
191
+ ec2 = MagicMock()
192
+ ec2.describe_volumes.return_value = {
193
+ "Volumes": [
194
+ {
195
+ "VolumeId": "vol-data1",
196
+ "Size": 96,
197
+ "State": "in-use",
198
+ "Attachments": [{"Device": "/dev/sdf", "InstanceId": "i-test123"}],
199
+ }
200
+ ]
201
+ }
202
+ volumes = find_ebs_volumes_for_instance(ec2, "i-test123", "aws-bootstrap-g4dn")
203
+ assert len(volumes) == 1
204
+ assert volumes[0]["VolumeId"] == "vol-data1"
205
+ assert volumes[0]["Size"] == 96
206
+ assert volumes[0]["Device"] == "/dev/sdf"
207
+ assert volumes[0]["State"] == "in-use"
208
+
209
+
210
+ def test_find_ebs_volumes_empty():
211
+ ec2 = MagicMock()
212
+ ec2.describe_volumes.return_value = {"Volumes": []}
213
+ volumes = find_ebs_volumes_for_instance(ec2, "i-test123", "aws-bootstrap-g4dn")
214
+ assert volumes == []
215
+
216
+
217
+ def test_find_ebs_volumes_includes_available():
218
+ """Detached (available) volumes are still discovered by tags."""
219
+ ec2 = MagicMock()
220
+ ec2.describe_volumes.return_value = {
221
+ "Volumes": [
222
+ {
223
+ "VolumeId": "vol-avail",
224
+ "Size": 50,
225
+ "State": "available",
226
+ "Attachments": [],
227
+ }
228
+ ]
229
+ }
230
+ volumes = find_ebs_volumes_for_instance(ec2, "i-old", "aws-bootstrap-g4dn")
231
+ assert len(volumes) == 1
232
+ assert volumes[0]["VolumeId"] == "vol-avail"
233
+ assert volumes[0]["State"] == "available"
234
+ assert volumes[0]["Device"] == ""
235
+
236
+
237
+ def test_find_ebs_volumes_client_error_returns_empty():
238
+ """ClientError (e.g. permissions) returns empty list instead of raising."""
239
+ ec2 = MagicMock()
240
+ ec2.describe_volumes.side_effect = botocore.exceptions.ClientError(
241
+ {"Error": {"Code": "UnauthorizedOperation", "Message": "no access"}},
242
+ "DescribeVolumes",
243
+ )
244
+ volumes = find_ebs_volumes_for_instance(ec2, "i-test", "aws-bootstrap-g4dn")
245
+ assert volumes == []