aws-bootstrap-g4dn 0.6.0__py3-none-any.whl → 0.8.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 +411 -101
- aws_bootstrap/ec2.py +45 -8
- aws_bootstrap/output.py +106 -0
- aws_bootstrap/ssh.py +21 -20
- aws_bootstrap/tests/test_cli.py +377 -0
- aws_bootstrap/tests/test_ebs.py +90 -0
- aws_bootstrap/tests/test_output.py +192 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/METADATA +34 -1
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/RECORD +13 -11
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/WHEEL +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/entry_points.txt +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {aws_bootstrap_g4dn-0.6.0.dist-info → aws_bootstrap_g4dn-0.8.0.dist-info}/top_level.txt +0 -0
aws_bootstrap/ec2.py
CHANGED
|
@@ -7,6 +7,7 @@ import botocore.exceptions
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
9
|
from .config import LaunchConfig
|
|
10
|
+
from .output import echo, is_text, secho
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
EBS_DEVICE_NAME = "/dev/sdf"
|
|
@@ -81,7 +82,7 @@ def ensure_security_group(ec2_client, name: str, tag_value: str, ssh_port: int =
|
|
|
81
82
|
if existing["SecurityGroups"]:
|
|
82
83
|
sg_id = existing["SecurityGroups"][0]["GroupId"]
|
|
83
84
|
msg = " Security group " + click.style(f"'{name}'", fg="bright_white")
|
|
84
|
-
|
|
85
|
+
echo(msg + f" already exists ({sg_id}), reusing.")
|
|
85
86
|
return sg_id
|
|
86
87
|
|
|
87
88
|
# Create new SG
|
|
@@ -113,7 +114,7 @@ def ensure_security_group(ec2_client, name: str, tag_value: str, ssh_port: int =
|
|
|
113
114
|
}
|
|
114
115
|
],
|
|
115
116
|
)
|
|
116
|
-
|
|
117
|
+
secho(f" Created security group '{name}' ({sg_id}) with SSH ingress.", fg="green")
|
|
117
118
|
return sg_id
|
|
118
119
|
|
|
119
120
|
|
|
@@ -163,8 +164,8 @@ def launch_instance(ec2_client, config: LaunchConfig, ami_id: str, sg_id: str) -
|
|
|
163
164
|
if code in ("MaxSpotInstanceCountExceeded", "VcpuLimitExceeded"):
|
|
164
165
|
_raise_quota_error(code, config)
|
|
165
166
|
elif code in ("InsufficientInstanceCapacity", "SpotMaxPriceTooLow") and config.spot:
|
|
166
|
-
|
|
167
|
-
if click.confirm(" Retry as on-demand instance?"):
|
|
167
|
+
secho(f"\n Spot request failed: {e.response['Error']['Message']}", fg="yellow")
|
|
168
|
+
if not is_text() or click.confirm(" Retry as on-demand instance?"):
|
|
168
169
|
launch_params.pop("InstanceMarketOptions", None)
|
|
169
170
|
try:
|
|
170
171
|
response = ec2_client.run_instances(**launch_params)
|
|
@@ -329,15 +330,15 @@ def terminate_tagged_instances(ec2_client, instance_ids: list[str]) -> list[dict
|
|
|
329
330
|
|
|
330
331
|
def wait_instance_ready(ec2_client, instance_id: str) -> dict:
|
|
331
332
|
"""Wait for the instance to be running and pass status checks."""
|
|
332
|
-
|
|
333
|
+
echo(" Waiting for instance " + click.style(instance_id, fg="bright_white") + " to enter 'running' state...")
|
|
333
334
|
waiter = ec2_client.get_waiter("instance_running")
|
|
334
335
|
waiter.wait(InstanceIds=[instance_id], WaiterConfig={"Delay": 10, "MaxAttempts": 60})
|
|
335
|
-
|
|
336
|
+
secho(" Instance running.", fg="green")
|
|
336
337
|
|
|
337
|
-
|
|
338
|
+
echo(" Waiting for instance status checks to pass...")
|
|
338
339
|
waiter = ec2_client.get_waiter("instance_status_ok")
|
|
339
340
|
waiter.wait(InstanceIds=[instance_id], WaiterConfig={"Delay": 15, "MaxAttempts": 60})
|
|
340
|
-
|
|
341
|
+
secho(" Status checks passed.", fg="green")
|
|
341
342
|
|
|
342
343
|
# Refresh instance info to get public IP
|
|
343
344
|
desc = ec2_client.describe_instances(InstanceIds=[instance_id])
|
|
@@ -467,3 +468,39 @@ def find_ebs_volumes_for_instance(ec2_client, instance_id: str, tag_value: str)
|
|
|
467
468
|
}
|
|
468
469
|
)
|
|
469
470
|
return volumes
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def find_orphan_ebs_volumes(ec2_client, tag_value: str, live_instance_ids: set[str]) -> list[dict]:
|
|
474
|
+
"""Find aws-bootstrap EBS volumes whose linked instance no longer exists.
|
|
475
|
+
|
|
476
|
+
Only returns volumes in ``available`` state (not attached to any instance).
|
|
477
|
+
Volumes that are ``in-use`` are never considered orphans, even if their
|
|
478
|
+
tagged instance ID is not in *live_instance_ids*.
|
|
479
|
+
|
|
480
|
+
Returns a list of dicts with VolumeId, Size, State, and InstanceId
|
|
481
|
+
(the instance ID from the ``aws-bootstrap-instance`` tag).
|
|
482
|
+
"""
|
|
483
|
+
try:
|
|
484
|
+
response = ec2_client.describe_volumes(
|
|
485
|
+
Filters=[
|
|
486
|
+
{"Name": "tag:created-by", "Values": [tag_value]},
|
|
487
|
+
{"Name": "status", "Values": ["available"]},
|
|
488
|
+
]
|
|
489
|
+
)
|
|
490
|
+
except botocore.exceptions.ClientError:
|
|
491
|
+
return []
|
|
492
|
+
|
|
493
|
+
orphans = []
|
|
494
|
+
for vol in response.get("Volumes", []):
|
|
495
|
+
tags = {t["Key"]: t["Value"] for t in vol.get("Tags", [])}
|
|
496
|
+
linked_instance = tags.get("aws-bootstrap-instance", "")
|
|
497
|
+
if linked_instance and linked_instance not in live_instance_ids:
|
|
498
|
+
orphans.append(
|
|
499
|
+
{
|
|
500
|
+
"VolumeId": vol["VolumeId"],
|
|
501
|
+
"Size": vol["Size"],
|
|
502
|
+
"State": vol["State"],
|
|
503
|
+
"InstanceId": linked_instance,
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
return orphans
|
aws_bootstrap/output.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Output formatting for structured CLI output (JSON, YAML, table, text)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutputFormat(StrEnum):
|
|
14
|
+
TEXT = "text"
|
|
15
|
+
JSON = "json"
|
|
16
|
+
YAML = "yaml"
|
|
17
|
+
TABLE = "table"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_format(ctx: click.Context | None = None) -> OutputFormat:
|
|
21
|
+
"""Return the current output format from the click context."""
|
|
22
|
+
if ctx is None:
|
|
23
|
+
ctx = click.get_current_context(silent=True)
|
|
24
|
+
if ctx is None or ctx.obj is None:
|
|
25
|
+
return OutputFormat.TEXT
|
|
26
|
+
return ctx.obj.get("output_format", OutputFormat.TEXT)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_text(ctx: click.Context | None = None) -> bool:
|
|
30
|
+
"""Return True if the current output format is text (default)."""
|
|
31
|
+
return get_format(ctx) == OutputFormat.TEXT
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _default_serializer(obj: Any) -> Any:
|
|
35
|
+
"""JSON serializer for objects not serializable by default."""
|
|
36
|
+
if isinstance(obj, datetime):
|
|
37
|
+
return obj.isoformat()
|
|
38
|
+
if isinstance(obj, Path):
|
|
39
|
+
return str(obj)
|
|
40
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def emit(data: dict | list, *, headers: dict[str, str] | None = None, ctx: click.Context | None = None) -> None:
|
|
44
|
+
"""Emit structured data in the configured output format.
|
|
45
|
+
|
|
46
|
+
For JSON/YAML: serializes the data directly.
|
|
47
|
+
For TABLE: renders using tabulate. If *data* is a list of dicts, uses
|
|
48
|
+
*headers* mapping ``{dict_key: column_label}`` for column selection/ordering.
|
|
49
|
+
If *data* is a single dict, renders as key-value pairs.
|
|
50
|
+
"""
|
|
51
|
+
fmt = get_format(ctx)
|
|
52
|
+
|
|
53
|
+
if fmt == OutputFormat.JSON:
|
|
54
|
+
click.echo(json.dumps(data, indent=2, default=_default_serializer))
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if fmt == OutputFormat.YAML:
|
|
58
|
+
import yaml # noqa: PLC0415
|
|
59
|
+
|
|
60
|
+
# Convert datetime/Path objects before YAML dump
|
|
61
|
+
prepared = json.loads(json.dumps(data, default=_default_serializer))
|
|
62
|
+
click.echo(yaml.dump(prepared, default_flow_style=False, sort_keys=False).rstrip())
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if fmt == OutputFormat.TABLE:
|
|
66
|
+
from tabulate import tabulate # noqa: PLC0415
|
|
67
|
+
|
|
68
|
+
table_data = data
|
|
69
|
+
# Unwrap dict-wrapped lists (e.g. {"instances": [...]}) for table rendering
|
|
70
|
+
if isinstance(data, dict) and headers:
|
|
71
|
+
for v in data.values():
|
|
72
|
+
if isinstance(v, list):
|
|
73
|
+
table_data = v
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
if isinstance(table_data, list) and table_data and isinstance(table_data[0], dict):
|
|
77
|
+
if headers:
|
|
78
|
+
keys = list(headers.keys())
|
|
79
|
+
col_labels = list(headers.values())
|
|
80
|
+
rows = [[row.get(k, "") for k in keys] for row in table_data]
|
|
81
|
+
else:
|
|
82
|
+
col_labels = list(table_data[0].keys())
|
|
83
|
+
keys = col_labels
|
|
84
|
+
rows = [[row.get(k, "") for k in keys] for row in table_data]
|
|
85
|
+
click.echo(tabulate(rows, headers=col_labels, tablefmt="simple"))
|
|
86
|
+
elif isinstance(table_data, dict):
|
|
87
|
+
rows = [[k, v] for k, v in table_data.items()]
|
|
88
|
+
click.echo(tabulate(rows, headers=["Key", "Value"], tablefmt="simple"))
|
|
89
|
+
elif isinstance(table_data, list):
|
|
90
|
+
# Empty list
|
|
91
|
+
click.echo("(no data)")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# TEXT format: emit() is a no-op in text mode (text output is handled inline)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def echo(msg: str = "", **kwargs: Any) -> None:
|
|
98
|
+
"""Wrap ``click.echo``; silent in non-text output modes."""
|
|
99
|
+
if is_text():
|
|
100
|
+
click.echo(msg, **kwargs)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def secho(msg: str = "", **kwargs: Any) -> None:
|
|
104
|
+
"""Wrap ``click.secho``; silent in non-text output modes."""
|
|
105
|
+
if is_text():
|
|
106
|
+
click.secho(msg, **kwargs)
|
aws_bootstrap/ssh.py
CHANGED
|
@@ -13,6 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
import click
|
|
14
14
|
|
|
15
15
|
from .gpu import _GPU_ARCHITECTURES, GpuInfo
|
|
16
|
+
from .output import echo, secho
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
# ---------------------------------------------------------------------------
|
|
@@ -54,7 +55,7 @@ def import_key_pair(ec2_client, key_name: str, key_path: Path) -> str:
|
|
|
54
55
|
# Check if key pair already exists
|
|
55
56
|
try:
|
|
56
57
|
existing = ec2_client.describe_key_pairs(KeyNames=[key_name])
|
|
57
|
-
|
|
58
|
+
echo(" Key pair " + click.style(f"'{key_name}'", fg="bright_white") + " already exists, reusing.")
|
|
58
59
|
return existing["KeyPairs"][0]["KeyName"]
|
|
59
60
|
except ec2_client.exceptions.ClientError as e:
|
|
60
61
|
if "InvalidKeyPair.NotFound" not in str(e):
|
|
@@ -70,7 +71,7 @@ def import_key_pair(ec2_client, key_name: str, key_path: Path) -> str:
|
|
|
70
71
|
}
|
|
71
72
|
],
|
|
72
73
|
)
|
|
73
|
-
|
|
74
|
+
secho(f" Imported key pair '{key_name}' from {key_path}", fg="green")
|
|
74
75
|
return key_name
|
|
75
76
|
|
|
76
77
|
|
|
@@ -88,7 +89,7 @@ def wait_for_ssh(host: str, user: str, key_path: Path, retries: int = 30, delay:
|
|
|
88
89
|
sock = socket.create_connection((host, port), timeout=5)
|
|
89
90
|
sock.close()
|
|
90
91
|
except (TimeoutError, ConnectionRefusedError, OSError):
|
|
91
|
-
|
|
92
|
+
echo(" SSH not ready " + click.style(f"(attempt {attempt}/{retries})", dim=True) + ", waiting...")
|
|
92
93
|
time.sleep(delay)
|
|
93
94
|
continue
|
|
94
95
|
|
|
@@ -106,10 +107,10 @@ def wait_for_ssh(host: str, user: str, key_path: Path, retries: int = 30, delay:
|
|
|
106
107
|
]
|
|
107
108
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
108
109
|
if result.returncode == 0:
|
|
109
|
-
|
|
110
|
+
secho(" SSH connection established.", fg="green")
|
|
110
111
|
return True
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
echo(" SSH not ready " + click.style(f"(attempt {attempt}/{retries})", dim=True) + ", waiting...")
|
|
113
114
|
time.sleep(delay)
|
|
114
115
|
|
|
115
116
|
return False
|
|
@@ -125,89 +126,89 @@ def run_remote_setup(
|
|
|
125
126
|
requirements_path = script_path.parent / "requirements.txt"
|
|
126
127
|
|
|
127
128
|
# SCP the requirements file
|
|
128
|
-
|
|
129
|
+
echo(" Uploading requirements.txt...")
|
|
129
130
|
req_result = subprocess.run(
|
|
130
131
|
["scp", *ssh_opts, *scp_port_opts, str(requirements_path), f"{user}@{host}:/tmp/requirements.txt"],
|
|
131
132
|
capture_output=True,
|
|
132
133
|
text=True,
|
|
133
134
|
)
|
|
134
135
|
if req_result.returncode != 0:
|
|
135
|
-
|
|
136
|
+
secho(f" SCP failed: {req_result.stderr}", fg="red", err=True)
|
|
136
137
|
return False
|
|
137
138
|
|
|
138
139
|
# SCP the GPU benchmark script
|
|
139
140
|
benchmark_path = script_path.parent / "gpu_benchmark.py"
|
|
140
|
-
|
|
141
|
+
echo(" Uploading gpu_benchmark.py...")
|
|
141
142
|
bench_result = subprocess.run(
|
|
142
143
|
["scp", *ssh_opts, *scp_port_opts, str(benchmark_path), f"{user}@{host}:/tmp/gpu_benchmark.py"],
|
|
143
144
|
capture_output=True,
|
|
144
145
|
text=True,
|
|
145
146
|
)
|
|
146
147
|
if bench_result.returncode != 0:
|
|
147
|
-
|
|
148
|
+
secho(f" SCP failed: {bench_result.stderr}", fg="red", err=True)
|
|
148
149
|
return False
|
|
149
150
|
|
|
150
151
|
# SCP the GPU smoke test notebook
|
|
151
152
|
notebook_path = script_path.parent / "gpu_smoke_test.ipynb"
|
|
152
|
-
|
|
153
|
+
echo(" Uploading gpu_smoke_test.ipynb...")
|
|
153
154
|
nb_result = subprocess.run(
|
|
154
155
|
["scp", *ssh_opts, *scp_port_opts, str(notebook_path), f"{user}@{host}:/tmp/gpu_smoke_test.ipynb"],
|
|
155
156
|
capture_output=True,
|
|
156
157
|
text=True,
|
|
157
158
|
)
|
|
158
159
|
if nb_result.returncode != 0:
|
|
159
|
-
|
|
160
|
+
secho(f" SCP failed: {nb_result.stderr}", fg="red", err=True)
|
|
160
161
|
return False
|
|
161
162
|
|
|
162
163
|
# SCP the CUDA example source
|
|
163
164
|
saxpy_path = script_path.parent / "saxpy.cu"
|
|
164
|
-
|
|
165
|
+
echo(" Uploading saxpy.cu...")
|
|
165
166
|
saxpy_result = subprocess.run(
|
|
166
167
|
["scp", *ssh_opts, *scp_port_opts, str(saxpy_path), f"{user}@{host}:/tmp/saxpy.cu"],
|
|
167
168
|
capture_output=True,
|
|
168
169
|
text=True,
|
|
169
170
|
)
|
|
170
171
|
if saxpy_result.returncode != 0:
|
|
171
|
-
|
|
172
|
+
secho(f" SCP failed: {saxpy_result.stderr}", fg="red", err=True)
|
|
172
173
|
return False
|
|
173
174
|
|
|
174
175
|
# SCP the VSCode launch.json
|
|
175
176
|
launch_json_path = script_path.parent / "launch.json"
|
|
176
|
-
|
|
177
|
+
echo(" Uploading launch.json...")
|
|
177
178
|
launch_result = subprocess.run(
|
|
178
179
|
["scp", *ssh_opts, *scp_port_opts, str(launch_json_path), f"{user}@{host}:/tmp/launch.json"],
|
|
179
180
|
capture_output=True,
|
|
180
181
|
text=True,
|
|
181
182
|
)
|
|
182
183
|
if launch_result.returncode != 0:
|
|
183
|
-
|
|
184
|
+
secho(f" SCP failed: {launch_result.stderr}", fg="red", err=True)
|
|
184
185
|
return False
|
|
185
186
|
|
|
186
187
|
# SCP the VSCode tasks.json
|
|
187
188
|
tasks_json_path = script_path.parent / "tasks.json"
|
|
188
|
-
|
|
189
|
+
echo(" Uploading tasks.json...")
|
|
189
190
|
tasks_result = subprocess.run(
|
|
190
191
|
["scp", *ssh_opts, *scp_port_opts, str(tasks_json_path), f"{user}@{host}:/tmp/tasks.json"],
|
|
191
192
|
capture_output=True,
|
|
192
193
|
text=True,
|
|
193
194
|
)
|
|
194
195
|
if tasks_result.returncode != 0:
|
|
195
|
-
|
|
196
|
+
secho(f" SCP failed: {tasks_result.stderr}", fg="red", err=True)
|
|
196
197
|
return False
|
|
197
198
|
|
|
198
199
|
# SCP the script
|
|
199
|
-
|
|
200
|
+
echo(" Uploading remote_setup.sh...")
|
|
200
201
|
scp_result = subprocess.run(
|
|
201
202
|
["scp", *ssh_opts, *scp_port_opts, str(script_path), f"{user}@{host}:/tmp/remote_setup.sh"],
|
|
202
203
|
capture_output=True,
|
|
203
204
|
text=True,
|
|
204
205
|
)
|
|
205
206
|
if scp_result.returncode != 0:
|
|
206
|
-
|
|
207
|
+
secho(f" SCP failed: {scp_result.stderr}", fg="red", err=True)
|
|
207
208
|
return False
|
|
208
209
|
|
|
209
210
|
# Execute the script, passing PYTHON_VERSION as an inline env var if specified
|
|
210
|
-
|
|
211
|
+
echo(" Running remote_setup.sh on instance...")
|
|
211
212
|
remote_cmd = "chmod +x /tmp/remote_setup.sh && "
|
|
212
213
|
if python_version:
|
|
213
214
|
remote_cmd += f"PYTHON_VERSION={python_version} "
|