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.
- aws_bootstrap/cli.py +502 -86
- aws_bootstrap/config.py +2 -0
- aws_bootstrap/ec2.py +137 -8
- aws_bootstrap/output.py +106 -0
- aws_bootstrap/resources/remote_setup.sh +2 -2
- aws_bootstrap/ssh.py +142 -20
- aws_bootstrap/tests/test_cli.py +652 -4
- aws_bootstrap/tests/test_config.py +18 -0
- aws_bootstrap/tests/test_ebs.py +245 -0
- aws_bootstrap/tests/test_output.py +192 -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.7.0.dist-info}/METADATA +79 -7
- aws_bootstrap_g4dn-0.7.0.dist-info/RECORD +31 -0
- aws_bootstrap_g4dn-0.5.0.dist-info/RECORD +0 -27
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.7.0.dist-info}/WHEEL +0 -0
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.7.0.dist-info}/entry_points.txt +0 -0
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {aws_bootstrap_g4dn-0.5.0.dist-info → aws_bootstrap_g4dn-0.7.0.dist-info}/top_level.txt +0 -0
aws_bootstrap/tests/test_cli.py
CHANGED
|
@@ -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 = {
|
|
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 = {
|
|
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": []}
|