aws-bootstrap-g4dn 0.1.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/__init__.py +1 -0
- aws_bootstrap/cli.py +438 -0
- aws_bootstrap/config.py +24 -0
- aws_bootstrap/ec2.py +341 -0
- aws_bootstrap/resources/__init__.py +0 -0
- aws_bootstrap/resources/gpu_benchmark.py +839 -0
- aws_bootstrap/resources/gpu_smoke_test.ipynb +340 -0
- aws_bootstrap/resources/remote_setup.sh +188 -0
- aws_bootstrap/resources/requirements.txt +8 -0
- aws_bootstrap/ssh.py +513 -0
- aws_bootstrap/tests/__init__.py +0 -0
- aws_bootstrap/tests/test_cli.py +528 -0
- aws_bootstrap/tests/test_config.py +35 -0
- aws_bootstrap/tests/test_ec2.py +313 -0
- aws_bootstrap/tests/test_ssh_config.py +297 -0
- aws_bootstrap/tests/test_ssh_gpu.py +138 -0
- aws_bootstrap_g4dn-0.1.0.dist-info/METADATA +308 -0
- aws_bootstrap_g4dn-0.1.0.dist-info/RECORD +22 -0
- aws_bootstrap_g4dn-0.1.0.dist-info/WHEEL +5 -0
- aws_bootstrap_g4dn-0.1.0.dist-info/entry_points.txt +2 -0
- aws_bootstrap_g4dn-0.1.0.dist-info/licenses/LICENSE +21 -0
- aws_bootstrap_g4dn-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Tests for EC2 helper functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import io
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
import botocore.exceptions
|
|
9
|
+
import click
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from aws_bootstrap.config import LaunchConfig
|
|
13
|
+
from aws_bootstrap.ec2 import (
|
|
14
|
+
CLIError,
|
|
15
|
+
find_tagged_instances,
|
|
16
|
+
get_latest_ami,
|
|
17
|
+
get_spot_price,
|
|
18
|
+
launch_instance,
|
|
19
|
+
list_amis,
|
|
20
|
+
list_instance_types,
|
|
21
|
+
terminate_tagged_instances,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_cli_error_is_click_exception():
|
|
26
|
+
err = CLIError("something went wrong")
|
|
27
|
+
assert isinstance(err, click.ClickException)
|
|
28
|
+
assert err.format_message() == "something went wrong"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_cli_error_show_outputs_red():
|
|
32
|
+
err = CLIError("bad input")
|
|
33
|
+
buf = io.StringIO()
|
|
34
|
+
err.show(file=buf)
|
|
35
|
+
output = buf.getvalue()
|
|
36
|
+
assert "Error: bad input" in output
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_get_latest_ami_picks_newest():
|
|
40
|
+
ec2 = MagicMock()
|
|
41
|
+
ec2.describe_images.return_value = {
|
|
42
|
+
"Images": [
|
|
43
|
+
{"ImageId": "ami-old", "Name": "DL AMI old", "CreationDate": "2024-01-01T00:00:00Z"},
|
|
44
|
+
{"ImageId": "ami-new", "Name": "DL AMI new", "CreationDate": "2025-06-01T00:00:00Z"},
|
|
45
|
+
{"ImageId": "ami-mid", "Name": "DL AMI mid", "CreationDate": "2025-01-01T00:00:00Z"},
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
ami = get_latest_ami(ec2, "DL AMI*")
|
|
49
|
+
assert ami["ImageId"] == "ami-new"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_get_latest_ami_no_results():
|
|
53
|
+
ec2 = MagicMock()
|
|
54
|
+
ec2.describe_images.return_value = {"Images": []}
|
|
55
|
+
with pytest.raises(click.ClickException, match="No AMI found"):
|
|
56
|
+
get_latest_ami(ec2, "nonexistent*")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _make_client_error(code: str, message: str = "test") -> botocore.exceptions.ClientError:
|
|
60
|
+
return botocore.exceptions.ClientError(
|
|
61
|
+
{"Error": {"Code": code, "Message": message}},
|
|
62
|
+
"RunInstances",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_launch_instance_spot_quota_exceeded():
|
|
67
|
+
ec2 = MagicMock()
|
|
68
|
+
ec2.run_instances.side_effect = _make_client_error("MaxSpotInstanceCountExceeded")
|
|
69
|
+
config = LaunchConfig(spot=True)
|
|
70
|
+
with pytest.raises(click.ClickException, match="Spot instance quota exceeded"):
|
|
71
|
+
launch_instance(ec2, config, "ami-test", "sg-test")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_launch_instance_vcpu_limit_exceeded():
|
|
75
|
+
ec2 = MagicMock()
|
|
76
|
+
ec2.run_instances.side_effect = _make_client_error("VcpuLimitExceeded")
|
|
77
|
+
config = LaunchConfig(spot=False)
|
|
78
|
+
with pytest.raises(click.ClickException, match="vCPU quota exceeded"):
|
|
79
|
+
launch_instance(ec2, config, "ami-test", "sg-test")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_launch_instance_quota_error_includes_readme_hint():
|
|
83
|
+
ec2 = MagicMock()
|
|
84
|
+
ec2.run_instances.side_effect = _make_client_error("MaxSpotInstanceCountExceeded")
|
|
85
|
+
config = LaunchConfig(spot=True)
|
|
86
|
+
with pytest.raises(click.ClickException, match="README.md"):
|
|
87
|
+
launch_instance(ec2, config, "ami-test", "sg-test")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_find_tagged_instances():
|
|
91
|
+
ec2 = MagicMock()
|
|
92
|
+
ec2.describe_instances.return_value = {
|
|
93
|
+
"Reservations": [
|
|
94
|
+
{
|
|
95
|
+
"Instances": [
|
|
96
|
+
{
|
|
97
|
+
"InstanceId": "i-abc123",
|
|
98
|
+
"State": {"Name": "running"},
|
|
99
|
+
"InstanceType": "g4dn.xlarge",
|
|
100
|
+
"PublicIpAddress": "1.2.3.4",
|
|
101
|
+
"LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
|
|
102
|
+
"InstanceLifecycle": "spot",
|
|
103
|
+
"Placement": {"AvailabilityZone": "us-west-2a"},
|
|
104
|
+
"Tags": [
|
|
105
|
+
{"Key": "Name", "Value": "aws-bootstrap-g4dn.xlarge"},
|
|
106
|
+
{"Key": "created-by", "Value": "aws-bootstrap-g4dn"},
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
|
|
114
|
+
assert len(instances) == 1
|
|
115
|
+
assert instances[0]["InstanceId"] == "i-abc123"
|
|
116
|
+
assert instances[0]["State"] == "running"
|
|
117
|
+
assert instances[0]["PublicIp"] == "1.2.3.4"
|
|
118
|
+
assert instances[0]["Name"] == "aws-bootstrap-g4dn.xlarge"
|
|
119
|
+
assert instances[0]["Lifecycle"] == "spot"
|
|
120
|
+
assert instances[0]["AvailabilityZone"] == "us-west-2a"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_find_tagged_instances_on_demand_lifecycle():
|
|
124
|
+
"""On-demand instances have no InstanceLifecycle key; should default to 'on-demand'."""
|
|
125
|
+
ec2 = MagicMock()
|
|
126
|
+
ec2.describe_instances.return_value = {
|
|
127
|
+
"Reservations": [
|
|
128
|
+
{
|
|
129
|
+
"Instances": [
|
|
130
|
+
{
|
|
131
|
+
"InstanceId": "i-ondemand",
|
|
132
|
+
"State": {"Name": "running"},
|
|
133
|
+
"InstanceType": "g4dn.xlarge",
|
|
134
|
+
"PublicIpAddress": "5.6.7.8",
|
|
135
|
+
"LaunchTime": datetime(2025, 1, 1, tzinfo=UTC),
|
|
136
|
+
"Placement": {"AvailabilityZone": "us-west-2b"},
|
|
137
|
+
"Tags": [
|
|
138
|
+
{"Key": "Name", "Value": "aws-bootstrap-g4dn.xlarge"},
|
|
139
|
+
],
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
|
|
146
|
+
assert len(instances) == 1
|
|
147
|
+
assert instances[0]["Lifecycle"] == "on-demand"
|
|
148
|
+
assert instances[0]["AvailabilityZone"] == "us-west-2b"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_find_tagged_instances_empty():
|
|
152
|
+
ec2 = MagicMock()
|
|
153
|
+
ec2.describe_instances.return_value = {"Reservations": []}
|
|
154
|
+
assert find_tagged_instances(ec2, "aws-bootstrap-g4dn") == []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_get_spot_price_returns_price():
|
|
158
|
+
ec2 = MagicMock()
|
|
159
|
+
ec2.describe_spot_price_history.return_value = {"SpotPriceHistory": [{"SpotPrice": "0.1578"}]}
|
|
160
|
+
price = get_spot_price(ec2, "g4dn.xlarge", "us-west-2a")
|
|
161
|
+
assert price == 0.1578
|
|
162
|
+
ec2.describe_spot_price_history.assert_called_once()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_get_spot_price_returns_none_when_empty():
|
|
166
|
+
ec2 = MagicMock()
|
|
167
|
+
ec2.describe_spot_price_history.return_value = {"SpotPriceHistory": []}
|
|
168
|
+
price = get_spot_price(ec2, "g4dn.xlarge", "us-west-2a")
|
|
169
|
+
assert price is None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_terminate_tagged_instances():
|
|
173
|
+
ec2 = MagicMock()
|
|
174
|
+
ec2.terminate_instances.return_value = {
|
|
175
|
+
"TerminatingInstances": [
|
|
176
|
+
{
|
|
177
|
+
"InstanceId": "i-abc123",
|
|
178
|
+
"PreviousState": {"Name": "running"},
|
|
179
|
+
"CurrentState": {"Name": "shutting-down"},
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
changes = terminate_tagged_instances(ec2, ["i-abc123"])
|
|
184
|
+
assert len(changes) == 1
|
|
185
|
+
assert changes[0]["InstanceId"] == "i-abc123"
|
|
186
|
+
ec2.terminate_instances.assert_called_once_with(InstanceIds=["i-abc123"])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# list_instance_types
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_list_instance_types_returns_sorted():
|
|
195
|
+
ec2 = MagicMock()
|
|
196
|
+
paginator = MagicMock()
|
|
197
|
+
ec2.get_paginator.return_value = paginator
|
|
198
|
+
paginator.paginate.return_value = [
|
|
199
|
+
{
|
|
200
|
+
"InstanceTypes": [
|
|
201
|
+
{
|
|
202
|
+
"InstanceType": "g4dn.xlarge",
|
|
203
|
+
"VCpuInfo": {"DefaultVCpus": 4},
|
|
204
|
+
"MemoryInfo": {"SizeInMiB": 16384},
|
|
205
|
+
"GpuInfo": {"Gpus": [{"Count": 1, "Name": "T4", "MemoryInfo": {"SizeInMiB": 16384}}]},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"InstanceType": "g4dn.2xlarge",
|
|
209
|
+
"VCpuInfo": {"DefaultVCpus": 8},
|
|
210
|
+
"MemoryInfo": {"SizeInMiB": 32768},
|
|
211
|
+
"GpuInfo": {"Gpus": [{"Count": 1, "Name": "T4", "MemoryInfo": {"SizeInMiB": 16384}}]},
|
|
212
|
+
},
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
results = list_instance_types(ec2, "g4dn")
|
|
217
|
+
assert len(results) == 2
|
|
218
|
+
# sorted by name — 2xlarge < xlarge lexicographically
|
|
219
|
+
assert results[0]["InstanceType"] == "g4dn.2xlarge"
|
|
220
|
+
assert results[1]["InstanceType"] == "g4dn.xlarge"
|
|
221
|
+
assert results[1]["VCpuCount"] == 4
|
|
222
|
+
assert results[1]["MemoryMiB"] == 16384
|
|
223
|
+
assert "T4" in results[1]["GpuSummary"]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_list_instance_types_no_gpu():
|
|
227
|
+
ec2 = MagicMock()
|
|
228
|
+
paginator = MagicMock()
|
|
229
|
+
ec2.get_paginator.return_value = paginator
|
|
230
|
+
paginator.paginate.return_value = [
|
|
231
|
+
{
|
|
232
|
+
"InstanceTypes": [
|
|
233
|
+
{
|
|
234
|
+
"InstanceType": "t3.medium",
|
|
235
|
+
"VCpuInfo": {"DefaultVCpus": 2},
|
|
236
|
+
"MemoryInfo": {"SizeInMiB": 4096},
|
|
237
|
+
},
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
results = list_instance_types(ec2, "t3")
|
|
242
|
+
assert len(results) == 1
|
|
243
|
+
assert results[0]["GpuSummary"] == ""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_list_instance_types_empty():
|
|
247
|
+
ec2 = MagicMock()
|
|
248
|
+
paginator = MagicMock()
|
|
249
|
+
ec2.get_paginator.return_value = paginator
|
|
250
|
+
paginator.paginate.return_value = [{"InstanceTypes": []}]
|
|
251
|
+
results = list_instance_types(ec2, "nonexistent")
|
|
252
|
+
assert results == []
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# list_amis
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_list_amis_sorted_newest_first():
|
|
261
|
+
ec2 = MagicMock()
|
|
262
|
+
ec2.describe_images.return_value = {
|
|
263
|
+
"Images": [
|
|
264
|
+
{
|
|
265
|
+
"ImageId": "ami-old",
|
|
266
|
+
"Name": "DL AMI old",
|
|
267
|
+
"CreationDate": "2024-01-01T00:00:00Z",
|
|
268
|
+
"Architecture": "x86_64",
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"ImageId": "ami-new",
|
|
272
|
+
"Name": "DL AMI new",
|
|
273
|
+
"CreationDate": "2025-06-01T00:00:00Z",
|
|
274
|
+
"Architecture": "x86_64",
|
|
275
|
+
},
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
results = list_amis(ec2, "DL AMI*")
|
|
279
|
+
assert len(results) == 2
|
|
280
|
+
assert results[0]["ImageId"] == "ami-new"
|
|
281
|
+
assert results[1]["ImageId"] == "ami-old"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_list_amis_empty():
|
|
285
|
+
ec2 = MagicMock()
|
|
286
|
+
ec2.describe_images.return_value = {"Images": []}
|
|
287
|
+
results = list_amis(ec2, "nonexistent*")
|
|
288
|
+
assert results == []
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_list_amis_limited_to_20():
|
|
292
|
+
ec2 = MagicMock()
|
|
293
|
+
ec2.describe_images.return_value = {
|
|
294
|
+
"Images": [
|
|
295
|
+
{
|
|
296
|
+
"ImageId": f"ami-{i:03d}",
|
|
297
|
+
"Name": f"AMI {i}",
|
|
298
|
+
"CreationDate": f"2025-01-{i + 1:02d}T00:00:00Z",
|
|
299
|
+
"Architecture": "x86_64",
|
|
300
|
+
}
|
|
301
|
+
for i in range(25)
|
|
302
|
+
]
|
|
303
|
+
}
|
|
304
|
+
results = list_amis(ec2, "AMI*")
|
|
305
|
+
assert len(results) == 20
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_list_amis_uses_owner_hint_for_deep_learning():
|
|
309
|
+
ec2 = MagicMock()
|
|
310
|
+
ec2.describe_images.return_value = {"Images": []}
|
|
311
|
+
list_amis(ec2, "Deep Learning Base*")
|
|
312
|
+
call_kwargs = ec2.describe_images.call_args[1]
|
|
313
|
+
assert call_kwargs["Owners"] == ["amazon"]
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Tests for SSH config management (add/remove/find/list host stanzas)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from aws_bootstrap.ssh import (
|
|
9
|
+
_next_alias,
|
|
10
|
+
_read_ssh_config,
|
|
11
|
+
add_ssh_host,
|
|
12
|
+
find_ssh_alias,
|
|
13
|
+
list_ssh_hosts,
|
|
14
|
+
remove_ssh_host,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Helpers
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
KEY_PATH = Path("/home/user/.ssh/id_ed25519.pub")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _config_path(tmp_path: Path) -> Path:
|
|
26
|
+
"""Return a config path inside tmp_path (doesn't create the file)."""
|
|
27
|
+
return tmp_path / ".ssh" / "config"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Stanza creation
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_add_creates_stanza(tmp_path):
|
|
36
|
+
cfg = _config_path(tmp_path)
|
|
37
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
38
|
+
content = cfg.read_text()
|
|
39
|
+
assert "# >>> aws-bootstrap [i-abc123] >>>" in content
|
|
40
|
+
assert "# <<< aws-bootstrap [i-abc123] <<<" in content
|
|
41
|
+
assert "Host aws-gpu1" in content
|
|
42
|
+
assert "HostName 1.2.3.4" in content
|
|
43
|
+
assert "User ubuntu" in content
|
|
44
|
+
assert "IdentityFile /home/user/.ssh/id_ed25519" in content
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_add_returns_alias(tmp_path):
|
|
48
|
+
cfg = _config_path(tmp_path)
|
|
49
|
+
alias = add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
50
|
+
assert alias == "aws-gpu1"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_add_uses_private_key_path(tmp_path):
|
|
54
|
+
cfg = _config_path(tmp_path)
|
|
55
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", Path("/keys/mykey.pub"), config_path=cfg)
|
|
56
|
+
content = cfg.read_text()
|
|
57
|
+
assert "IdentityFile /keys/mykey" in content
|
|
58
|
+
assert ".pub" not in content.split("IdentityFile")[1].split("\n")[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_add_includes_user(tmp_path):
|
|
62
|
+
cfg = _config_path(tmp_path)
|
|
63
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ec2-user", KEY_PATH, config_path=cfg)
|
|
64
|
+
content = cfg.read_text()
|
|
65
|
+
assert "User ec2-user" in content
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_add_includes_strict_host_checking(tmp_path):
|
|
69
|
+
cfg = _config_path(tmp_path)
|
|
70
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
71
|
+
content = cfg.read_text()
|
|
72
|
+
assert "StrictHostKeyChecking no" in content
|
|
73
|
+
assert "UserKnownHostsFile /dev/null" in content
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Multiple instances
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_second_host_gets_gpu2(tmp_path):
|
|
82
|
+
cfg = _config_path(tmp_path)
|
|
83
|
+
a1 = add_ssh_host("i-111", "1.1.1.1", "ubuntu", KEY_PATH, config_path=cfg)
|
|
84
|
+
a2 = add_ssh_host("i-222", "2.2.2.2", "ubuntu", KEY_PATH, config_path=cfg)
|
|
85
|
+
assert a1 == "aws-gpu1"
|
|
86
|
+
assert a2 == "aws-gpu2"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_next_alias_empty():
|
|
90
|
+
assert _next_alias("") == "aws-gpu1"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_next_alias_custom_prefix():
|
|
94
|
+
assert _next_alias("", prefix="dev-box") == "dev-box1"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_next_alias_skips_user_hosts():
|
|
98
|
+
"""User-defined hosts with similar names should be ignored."""
|
|
99
|
+
content = "Host aws-gpu99\n HostName 1.2.3.4\n"
|
|
100
|
+
# No marker blocks, so this should be ignored
|
|
101
|
+
assert _next_alias(content) == "aws-gpu1"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Preserving existing config
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_preserves_existing_stanzas(tmp_path):
|
|
110
|
+
cfg = _config_path(tmp_path)
|
|
111
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
existing = "Host myserver\n HostName 10.0.0.1\n User admin\n"
|
|
113
|
+
cfg.write_text(existing)
|
|
114
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
115
|
+
content = cfg.read_text()
|
|
116
|
+
assert "Host myserver" in content
|
|
117
|
+
assert "HostName 10.0.0.1" in content
|
|
118
|
+
assert "Host aws-gpu1" in content
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_preserves_trailing_newline(tmp_path):
|
|
122
|
+
cfg = _config_path(tmp_path)
|
|
123
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
cfg.write_text("Host foo\n HostName bar\n")
|
|
125
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
126
|
+
content = cfg.read_text()
|
|
127
|
+
# Should not have triple+ blank lines
|
|
128
|
+
assert "\n\n\n" not in content
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_add_to_nonexistent_creates_dir_and_file(tmp_path):
|
|
132
|
+
cfg = tmp_path / "brand_new" / ".ssh" / "config"
|
|
133
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
134
|
+
assert cfg.exists()
|
|
135
|
+
assert cfg.parent.exists()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Removal
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_remove_returns_alias(tmp_path):
|
|
144
|
+
cfg = _config_path(tmp_path)
|
|
145
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
146
|
+
removed = remove_ssh_host("i-abc123", config_path=cfg)
|
|
147
|
+
assert removed == "aws-gpu1"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_remove_returns_none_when_missing(tmp_path):
|
|
151
|
+
cfg = _config_path(tmp_path)
|
|
152
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
cfg.write_text("")
|
|
154
|
+
assert remove_ssh_host("i-missing", config_path=cfg) is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_remove_preserves_other_stanzas(tmp_path):
|
|
158
|
+
cfg = _config_path(tmp_path)
|
|
159
|
+
add_ssh_host("i-111", "1.1.1.1", "ubuntu", KEY_PATH, config_path=cfg)
|
|
160
|
+
add_ssh_host("i-222", "2.2.2.2", "ubuntu", KEY_PATH, config_path=cfg)
|
|
161
|
+
remove_ssh_host("i-111", config_path=cfg)
|
|
162
|
+
content = cfg.read_text()
|
|
163
|
+
assert "i-111" not in content
|
|
164
|
+
assert "Host aws-gpu2" in content
|
|
165
|
+
assert "HostName 2.2.2.2" in content
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_remove_preserves_user_config(tmp_path):
|
|
169
|
+
cfg = _config_path(tmp_path)
|
|
170
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
cfg.write_text("Host myserver\n HostName 10.0.0.1\n")
|
|
172
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
173
|
+
remove_ssh_host("i-abc123", config_path=cfg)
|
|
174
|
+
content = cfg.read_text()
|
|
175
|
+
assert "Host myserver" in content
|
|
176
|
+
assert "HostName 10.0.0.1" in content
|
|
177
|
+
assert "aws-gpu" not in content
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_remove_cleans_trailing_blanks(tmp_path):
|
|
181
|
+
cfg = _config_path(tmp_path)
|
|
182
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
183
|
+
remove_ssh_host("i-abc123", config_path=cfg)
|
|
184
|
+
content = cfg.read_text()
|
|
185
|
+
assert "\n\n\n" not in content
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Idempotency
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_add_idempotent_updates_ip(tmp_path):
|
|
194
|
+
cfg = _config_path(tmp_path)
|
|
195
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
196
|
+
add_ssh_host("i-abc123", "5.6.7.8", "ubuntu", KEY_PATH, config_path=cfg)
|
|
197
|
+
content = cfg.read_text()
|
|
198
|
+
assert "HostName 5.6.7.8" in content
|
|
199
|
+
assert "HostName 1.2.3.4" not in content
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_add_idempotent_preserves_alias(tmp_path):
|
|
203
|
+
cfg = _config_path(tmp_path)
|
|
204
|
+
a1 = add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
205
|
+
a2 = add_ssh_host("i-abc123", "5.6.7.8", "ubuntu", KEY_PATH, config_path=cfg)
|
|
206
|
+
assert a1 == a2 == "aws-gpu1"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Lookup
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_find_alias_returns_alias(tmp_path):
|
|
215
|
+
cfg = _config_path(tmp_path)
|
|
216
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
217
|
+
assert find_ssh_alias("i-abc123", config_path=cfg) == "aws-gpu1"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_find_alias_returns_none(tmp_path):
|
|
221
|
+
cfg = _config_path(tmp_path)
|
|
222
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
cfg.write_text("")
|
|
224
|
+
assert find_ssh_alias("i-missing", config_path=cfg) is None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_list_hosts_returns_all(tmp_path):
|
|
228
|
+
cfg = _config_path(tmp_path)
|
|
229
|
+
add_ssh_host("i-111", "1.1.1.1", "ubuntu", KEY_PATH, config_path=cfg)
|
|
230
|
+
add_ssh_host("i-222", "2.2.2.2", "ubuntu", KEY_PATH, config_path=cfg)
|
|
231
|
+
hosts = list_ssh_hosts(config_path=cfg)
|
|
232
|
+
assert hosts == {"i-111": "aws-gpu1", "i-222": "aws-gpu2"}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_list_hosts_ignores_user_stanzas(tmp_path):
|
|
236
|
+
cfg = _config_path(tmp_path)
|
|
237
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
cfg.write_text("Host myserver\n HostName 10.0.0.1\n")
|
|
239
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
240
|
+
hosts = list_ssh_hosts(config_path=cfg)
|
|
241
|
+
assert "myserver" not in hosts.values()
|
|
242
|
+
assert hosts == {"i-abc123": "aws-gpu1"}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Safety / edge cases
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_file_permissions_0600(tmp_path):
|
|
251
|
+
cfg = _config_path(tmp_path)
|
|
252
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
253
|
+
mode = stat.S_IMODE(os.stat(cfg).st_mode)
|
|
254
|
+
assert mode == 0o600
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_dir_permissions_0700(tmp_path):
|
|
258
|
+
cfg = _config_path(tmp_path)
|
|
259
|
+
add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
260
|
+
mode = stat.S_IMODE(os.stat(cfg.parent).st_mode)
|
|
261
|
+
assert mode == 0o700
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_handles_empty_file(tmp_path):
|
|
265
|
+
cfg = _config_path(tmp_path)
|
|
266
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
cfg.write_text("")
|
|
268
|
+
alias = add_ssh_host("i-abc123", "1.2.3.4", "ubuntu", KEY_PATH, config_path=cfg)
|
|
269
|
+
assert alias == "aws-gpu1"
|
|
270
|
+
assert "Host aws-gpu1" in cfg.read_text()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_malformed_marker_left_alone(tmp_path):
|
|
274
|
+
"""Orphaned begin marker without end marker should not cause deletion."""
|
|
275
|
+
cfg = _config_path(tmp_path)
|
|
276
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
orphaned = "# >>> aws-bootstrap [i-orphan] >>>\nHost aws-gpu99\n HostName 9.9.9.9\n"
|
|
278
|
+
cfg.write_text(orphaned)
|
|
279
|
+
removed = remove_ssh_host("i-orphan", config_path=cfg)
|
|
280
|
+
assert removed is None
|
|
281
|
+
content = cfg.read_text()
|
|
282
|
+
assert "aws-gpu99" in content
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_read_nonexistent_returns_empty(tmp_path):
|
|
286
|
+
cfg = tmp_path / "does_not_exist"
|
|
287
|
+
assert _read_ssh_config(cfg) == ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_list_hosts_nonexistent_file(tmp_path):
|
|
291
|
+
cfg = tmp_path / "no_such_file"
|
|
292
|
+
assert list_ssh_hosts(config_path=cfg) == {}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_remove_nonexistent_file(tmp_path):
|
|
296
|
+
cfg = tmp_path / "no_such_file"
|
|
297
|
+
assert remove_ssh_host("i-abc123", config_path=cfg) is None
|