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.
- aws_bootstrap/cli.py +172 -8
- aws_bootstrap/config.py +2 -0
- aws_bootstrap/ec2.py +128 -0
- aws_bootstrap/resources/remote_setup.sh +2 -2
- aws_bootstrap/ssh.py +121 -0
- aws_bootstrap/tests/test_cli.py +372 -4
- aws_bootstrap/tests/test_config.py +18 -0
- aws_bootstrap/tests/test_ebs.py +245 -0
- aws_bootstrap/tests/test_ssh_config.py +76 -0
- aws_bootstrap/tests/test_ssh_ebs.py +76 -0
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.6.0.dist-info}/METADATA +53 -7
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.6.0.dist-info}/RECORD +16 -14
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.6.0.dist-info}/WHEEL +0 -0
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.6.0.dist-info}/entry_points.txt +0 -0
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.6.0.dist-info}/top_level.txt +0 -0
aws_bootstrap/tests/test_cli.py
CHANGED
|
@@ -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 = {
|
|
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 = {
|
|
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 == []
|