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.
@@ -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