aws-ssh-utils 0.3.3__tar.gz → 0.4.0__tar.gz
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_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/PKG-INFO +1 -1
- aws_ssh_utils-0.4.0/aws_ssh_utils/__init__.py +1 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/app.py +492 -378
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ec2_utils.py +77 -68
- aws_ssh_utils-0.3.3/aws_ssh_utils/__init__.py +0 -1
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/.gitignore +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/LICENSE +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/README.md +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/cli_utils.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/__init__.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/ec2.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr_all.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/healthcheck.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/connection.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/emr_utils.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/logging_utils.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/py.typed +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/session.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ssh.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/terminal.py +0 -0
- {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/pyproject.toml +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -1,378 +1,492 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
import time
|
|
3
|
-
from collections.abc import Callable
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from itertools import count
|
|
6
|
-
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
|
|
7
|
-
|
|
8
|
-
import boto3
|
|
9
|
-
import click
|
|
10
|
-
from botocore.exceptions import BotoCoreError, ClientError
|
|
11
|
-
from loguru import logger
|
|
12
|
-
from rich.console import Console
|
|
13
|
-
from rich.markup import escape
|
|
14
|
-
from rich.spinner import Spinner
|
|
15
|
-
from rich.
|
|
16
|
-
from
|
|
17
|
-
from textual
|
|
18
|
-
from textual.
|
|
19
|
-
from textual.
|
|
20
|
-
from textual.
|
|
21
|
-
from textual.
|
|
22
|
-
from textual.
|
|
23
|
-
|
|
24
|
-
from
|
|
25
|
-
from
|
|
26
|
-
|
|
27
|
-
from aws_ssh_utils.
|
|
28
|
-
from aws_ssh_utils.
|
|
29
|
-
from aws_ssh_utils.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
from
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
self.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
self.
|
|
224
|
-
self.
|
|
225
|
-
self.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def
|
|
302
|
-
self.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
self.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
self.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def
|
|
352
|
-
self.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from itertools import count
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
import click
|
|
10
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
11
|
+
from loguru import logger
|
|
12
|
+
from rich.console import Console, Group
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
from rich.spinner import Spinner
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from textual import events, work
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.binding import Binding, BindingType
|
|
20
|
+
from textual.containers import Horizontal
|
|
21
|
+
from textual.geometry import Offset
|
|
22
|
+
from textual.screen import ModalScreen
|
|
23
|
+
from textual.widgets import Footer, Input, OptionList, RichLog, Static, TabbedContent, TabPane, Tree
|
|
24
|
+
from textual.widgets.option_list import Option
|
|
25
|
+
from textual.widgets.tree import TreeNode
|
|
26
|
+
|
|
27
|
+
from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, render_attempts, scrollback_option
|
|
28
|
+
from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
|
|
29
|
+
from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances, get_vpc_names
|
|
30
|
+
from aws_ssh_utils.emr_utils import emr_target, get_emr_clusters, get_emr_instances, group_role, group_sort_key
|
|
31
|
+
from aws_ssh_utils.logging_utils import terminal_logging
|
|
32
|
+
from aws_ssh_utils.terminal import ShellStatus, Terminal, TerminalApp
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from mypy_boto3_ec2 import EC2Client
|
|
36
|
+
from mypy_boto3_ec2.type_defs import InstanceTypeDef as EC2InstanceTypeDef
|
|
37
|
+
from mypy_boto3_emr import EMRClient
|
|
38
|
+
from mypy_boto3_emr.type_defs import InstanceTypeDef as EMRInstanceTypeDef
|
|
39
|
+
|
|
40
|
+
Status = Literal['connecting', 'waiting', 'connected', 'failed']
|
|
41
|
+
STATUS_ICONS: dict[Status, Text] = {
|
|
42
|
+
'waiting': Text('⚠', style='yellow'),
|
|
43
|
+
'connected': Text('●', style='green'),
|
|
44
|
+
'failed': Text('●', style='red'),
|
|
45
|
+
}
|
|
46
|
+
ANIMATION_INTERVAL = 1 / 12
|
|
47
|
+
RIGHT_MOUSE_BUTTON = 3
|
|
48
|
+
PUBLIC_IP_ICON = '\U000f01e7' # nf-md-earth
|
|
49
|
+
PRIVATE_IP_ICON = '' # nf-oct-lock
|
|
50
|
+
INSTANCE_TYPE_ICON = '' # nf-oct-cpu
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.command("app")
|
|
54
|
+
@click.option('-p', '--profile', default=None, help='Which AWS profile to use')
|
|
55
|
+
@click.option('-r', '--region', default=None, help='Which AWS region to use')
|
|
56
|
+
@scrollback_option
|
|
57
|
+
def app(profile: str | None = None, region: str | None = None, scrollback: int | None = None, **kwargs: Any):
|
|
58
|
+
"""Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs."""
|
|
59
|
+
try:
|
|
60
|
+
session = boto3.Session(profile_name=profile, region_name=region)
|
|
61
|
+
session.client('sts').get_caller_identity()
|
|
62
|
+
except (BotoCoreError, ClientError) as e:
|
|
63
|
+
logger.bind(file_only=True).opt(exception=e).error('AWS authentication failed')
|
|
64
|
+
Console().print(f"[red]✗[/] {escape(str(e))}")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
env = detect_environment(profile, session.region_name)
|
|
68
|
+
scrollback = detect_scrollback() if scrollback is None else scrollback
|
|
69
|
+
with terminal_logging():
|
|
70
|
+
SSHApp(session.client('ec2'), session.client('emr'), env, scrollback).run()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def muted_label(name: str, instance_id: str) -> Text:
|
|
74
|
+
return Text.assemble(name, (f" - {instance_id}", 'dim'))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ec2_label(instance: "EC2InstanceTypeDef") -> Table:
|
|
78
|
+
"""Name and id, with a right-aligned globe when the instance has a public IP."""
|
|
79
|
+
grid = Table.grid(expand=True)
|
|
80
|
+
grid.add_column(ratio=1)
|
|
81
|
+
grid.add_column(justify='right')
|
|
82
|
+
grid.add_row(
|
|
83
|
+
muted_label(ec2_name(instance), instance.get('InstanceId', '')),
|
|
84
|
+
PUBLIC_IP_ICON if instance.get('PublicIpAddress') else '',
|
|
85
|
+
)
|
|
86
|
+
return grid
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Cluster:
|
|
91
|
+
id: str
|
|
92
|
+
name: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class EMRNode:
|
|
97
|
+
cluster_id: str
|
|
98
|
+
group_name: str
|
|
99
|
+
instance: "EMRInstanceTypeDef"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BouncingBall(Static):
|
|
103
|
+
def on_mount(self):
|
|
104
|
+
spinner = Spinner('bouncingBall')
|
|
105
|
+
self.set_interval(ANIMATION_INTERVAL, lambda: self.update(spinner.render(time.monotonic())))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EC2List(OptionList):
|
|
109
|
+
"""Only the left button selects; right clicks bubble up to open a context menu."""
|
|
110
|
+
|
|
111
|
+
async def _on_click(self, event: events.Click) -> None:
|
|
112
|
+
if event.button == RIGHT_MOUSE_BUTTON:
|
|
113
|
+
event.prevent_default()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ContextMenu(ModalScreen[str | None]):
|
|
117
|
+
"""Options at the mouse position. Dismisses with the chosen option id, or None."""
|
|
118
|
+
|
|
119
|
+
DEFAULT_CSS = """
|
|
120
|
+
ContextMenu { background: transparent; }
|
|
121
|
+
ContextMenu > OptionList {
|
|
122
|
+
position: absolute;
|
|
123
|
+
constrain: inside inside;
|
|
124
|
+
width: auto;
|
|
125
|
+
height: auto;
|
|
126
|
+
padding: 0;
|
|
127
|
+
&, &:focus { border: round $border; }
|
|
128
|
+
}
|
|
129
|
+
"""
|
|
130
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding('escape', 'dismiss', 'Close')]
|
|
131
|
+
|
|
132
|
+
def __init__(self, offset: Offset, options: list[Option]):
|
|
133
|
+
super().__init__()
|
|
134
|
+
self.menu_offset = offset
|
|
135
|
+
self.menu_options = options
|
|
136
|
+
|
|
137
|
+
def compose(self) -> ComposeResult:
|
|
138
|
+
yield OptionList(*self.menu_options)
|
|
139
|
+
|
|
140
|
+
def on_mount(self):
|
|
141
|
+
menu = self.query_one(OptionList)
|
|
142
|
+
menu.styles.offset = self.menu_offset
|
|
143
|
+
menu.focus()
|
|
144
|
+
|
|
145
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected):
|
|
146
|
+
event.stop()
|
|
147
|
+
self.dismiss(event.option_id)
|
|
148
|
+
|
|
149
|
+
def on_click(self, event: events.Click):
|
|
150
|
+
if event.widget is self:
|
|
151
|
+
self.dismiss()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ShellPane(TabPane):
|
|
155
|
+
"""Connects in the background, then swaps its log for a Terminal."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, title: str, resolve: Callable[[], Target], env: Environment, scrollback: int, **kwargs: Any):
|
|
158
|
+
super().__init__(title, **kwargs)
|
|
159
|
+
self.title_ = title
|
|
160
|
+
self.resolve = resolve
|
|
161
|
+
self.env = env
|
|
162
|
+
self.scrollback = scrollback
|
|
163
|
+
self.status: Status = 'connecting'
|
|
164
|
+
self.spinner = Spinner('dots')
|
|
165
|
+
self.attempts: list[Attempt] = []
|
|
166
|
+
|
|
167
|
+
def compose(self) -> ComposeResult:
|
|
168
|
+
yield Static(classes='attempts')
|
|
169
|
+
yield RichLog(wrap=True, markup=True)
|
|
170
|
+
|
|
171
|
+
def on_mount(self):
|
|
172
|
+
self.animation = self.set_interval(ANIMATION_INTERVAL, self.update_label)
|
|
173
|
+
self.connect()
|
|
174
|
+
|
|
175
|
+
def update_label(self):
|
|
176
|
+
icon = cast(Text, self.spinner.render(time.monotonic())) if self.status == 'connecting' else STATUS_ICONS[self.status]
|
|
177
|
+
self.query_ancestor(TabbedContent).get_tab(self).label = Text.assemble(icon, ' ', self.title_)
|
|
178
|
+
|
|
179
|
+
def set_attempts(self, attempts: list[Attempt]):
|
|
180
|
+
self.attempts = attempts
|
|
181
|
+
self.query_one('.attempts', Static).update(render_attempts(self.attempts))
|
|
182
|
+
self.update_label()
|
|
183
|
+
|
|
184
|
+
def set_status(self, status: Status):
|
|
185
|
+
self.status = status
|
|
186
|
+
if status != 'connecting':
|
|
187
|
+
self.animation.stop()
|
|
188
|
+
self.update_label()
|
|
189
|
+
|
|
190
|
+
def write(self, markup: str):
|
|
191
|
+
self.query_one(RichLog).write(markup)
|
|
192
|
+
|
|
193
|
+
@work(thread=True, exit_on_error=False)
|
|
194
|
+
def connect(self):
|
|
195
|
+
def write(line: str):
|
|
196
|
+
self.app.call_from_thread(self.write, escape(line))
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
result = connect(
|
|
200
|
+
self.resolve(),
|
|
201
|
+
self.env,
|
|
202
|
+
on_output=write,
|
|
203
|
+
on_waiting=lambda: self.app.call_from_thread(self.set_status, 'waiting'),
|
|
204
|
+
on_progress=lambda attempts: self.app.call_from_thread(self.set_attempts, attempts),
|
|
205
|
+
)
|
|
206
|
+
if result is None:
|
|
207
|
+
self.app.call_from_thread(self.set_status, 'failed')
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
channel = open_shell_channel(result.client)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.exception("Connection failed")
|
|
213
|
+
self.app.call_from_thread(self.write, f"[red]✗ {escape(str(e) or type(e).__name__)}[/]")
|
|
214
|
+
self.app.call_from_thread(self.set_status, 'failed')
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
self.app.call_from_thread(self.attach, result.via, Terminal(result.client, channel, self.scrollback))
|
|
218
|
+
|
|
219
|
+
async def attach(self, via: str, terminal: Terminal):
|
|
220
|
+
logger.info('Shell tab {} connected via {}', self.id, via)
|
|
221
|
+
# Stop animation before yielding to widget removal or mounting a reader
|
|
222
|
+
# that may immediately close the session.
|
|
223
|
+
self.set_status('connected')
|
|
224
|
+
await self.query_one(RichLog).remove()
|
|
225
|
+
await self.query_one('.attempts').remove()
|
|
226
|
+
await self.mount(ShellStatus(via), terminal)
|
|
227
|
+
|
|
228
|
+
def on_terminal_closed(self):
|
|
229
|
+
logger.info('Shell tab {} closed', self.id)
|
|
230
|
+
self.query_ancestor(TabbedContent).remove_pane(self.id or '')
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class SSHApp(TerminalApp):
|
|
234
|
+
"""The textual app"""
|
|
235
|
+
|
|
236
|
+
TITLE = "AWS SSH"
|
|
237
|
+
CSS = """
|
|
238
|
+
#sidebar { width: 60; }
|
|
239
|
+
#shells { width: 1fr; }
|
|
240
|
+
Footer { background: $surface; }
|
|
241
|
+
TabbedContent, TabbedContent > ContentSwitcher, TabPane { height: 1fr; }
|
|
242
|
+
#ec2-list, #emr-tree { height: 1fr; }
|
|
243
|
+
#ec2-details { height: auto; }
|
|
244
|
+
#emr-tree, #ec2-details {
|
|
245
|
+
border: tall $border-blurred;
|
|
246
|
+
padding: 0 1;
|
|
247
|
+
background: $surface;
|
|
248
|
+
&:focus { border: tall $border; }
|
|
249
|
+
}
|
|
250
|
+
.error { color: $error; }
|
|
251
|
+
"""
|
|
252
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
253
|
+
Binding('ctrl+q', 'quit', 'Quit', priority=True),
|
|
254
|
+
Binding('f12', 'toggle_sidebar', 'Toggle sidebar', priority=True),
|
|
255
|
+
Binding('ctrl+w', 'close_tab', 'Close tab'),
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
def __init__(self, ec2: "EC2Client", emr: "EMRClient", env: Environment, scrollback: int):
|
|
259
|
+
super().__init__()
|
|
260
|
+
self.ec2 = ec2
|
|
261
|
+
self.emr = emr
|
|
262
|
+
self.env = env
|
|
263
|
+
self.scrollback = scrollback
|
|
264
|
+
self.ec2_instances: list[EC2InstanceTypeDef] = []
|
|
265
|
+
self.vpc_names: dict[str, str] = {}
|
|
266
|
+
self.clusters: list[Cluster] = []
|
|
267
|
+
self.cluster_instances: dict[str, dict[str, list[EMRInstanceTypeDef]]] = {}
|
|
268
|
+
self.emr_loading = 0
|
|
269
|
+
self.pane_ids = count()
|
|
270
|
+
|
|
271
|
+
def compose(self) -> ComposeResult:
|
|
272
|
+
with Horizontal():
|
|
273
|
+
with TabbedContent(id='sidebar'):
|
|
274
|
+
with TabPane('EC2'):
|
|
275
|
+
yield Input(placeholder='Filter by name', id='ec2-filter')
|
|
276
|
+
yield BouncingBall(id='ec2-loading')
|
|
277
|
+
yield Static(id='ec2-error', classes='error')
|
|
278
|
+
yield EC2List(id='ec2-list')
|
|
279
|
+
yield Static(id='ec2-details')
|
|
280
|
+
with TabPane('EMR'):
|
|
281
|
+
yield Input(placeholder='Filter by name', id='emr-filter')
|
|
282
|
+
yield BouncingBall(id='emr-loading')
|
|
283
|
+
yield Static(id='emr-error', classes='error')
|
|
284
|
+
yield Tree('Clusters', id='emr-tree')
|
|
285
|
+
yield TabbedContent(id='shells')
|
|
286
|
+
yield Footer()
|
|
287
|
+
|
|
288
|
+
def on_mount(self):
|
|
289
|
+
self.query_one('#ec2-error').display = False
|
|
290
|
+
self.query_one('#emr-error').display = False
|
|
291
|
+
self.query_one('#emr-tree', Tree).show_root = False
|
|
292
|
+
self.load_ec2()
|
|
293
|
+
self.load_emr()
|
|
294
|
+
|
|
295
|
+
def show_error(self, prefix: str, error: Exception):
|
|
296
|
+
self.query_one(f'#{prefix}-loading').display = False
|
|
297
|
+
error_widget = self.query_one(f'#{prefix}-error', Static)
|
|
298
|
+
error_widget.update(Text(str(error)))
|
|
299
|
+
error_widget.display = True
|
|
300
|
+
|
|
301
|
+
def set_emr_loading(self, delta: int):
|
|
302
|
+
self.emr_loading += delta
|
|
303
|
+
self.query_one('#emr-loading').display = self.emr_loading > 0
|
|
304
|
+
|
|
305
|
+
def on_input_changed(self, event: Input.Changed):
|
|
306
|
+
if event.input.id == 'ec2-filter':
|
|
307
|
+
self.filter_ec2()
|
|
308
|
+
else:
|
|
309
|
+
self.filter_emr()
|
|
310
|
+
|
|
311
|
+
def action_toggle_sidebar(self):
|
|
312
|
+
sidebar = self.query_one('#sidebar')
|
|
313
|
+
sidebar.display = not sidebar.display
|
|
314
|
+
if not sidebar.display and self.focused and sidebar in self.focused.ancestors_with_self:
|
|
315
|
+
pane = self.query_one('#shells', TabbedContent).active_pane
|
|
316
|
+
self.set_focus(pane.query(Terminal).first() if pane and pane.query(Terminal) else None)
|
|
317
|
+
|
|
318
|
+
def action_close_tab(self):
|
|
319
|
+
shells = self.query_one('#shells', TabbedContent)
|
|
320
|
+
if shells.active:
|
|
321
|
+
shells.remove_pane(shells.active)
|
|
322
|
+
|
|
323
|
+
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated):
|
|
324
|
+
if event.tabbed_content.id != 'shells' or event.pane.id != event.tabbed_content.active:
|
|
325
|
+
return
|
|
326
|
+
if terminals := event.pane.query(Terminal):
|
|
327
|
+
terminals.first().focus()
|
|
328
|
+
|
|
329
|
+
def open_shell(self, title: str, resolve: Callable[[], Target]):
|
|
330
|
+
shells = self.query_one('#shells', TabbedContent)
|
|
331
|
+
pane = ShellPane(title, resolve, self.env, self.scrollback, id=f'shell-{next(self.pane_ids)}')
|
|
332
|
+
logger.info('Opening shell tab {} ({})', pane.id, title)
|
|
333
|
+
shells.add_pane(pane)
|
|
334
|
+
shells.active = pane.id or ''
|
|
335
|
+
|
|
336
|
+
# region - EC2
|
|
337
|
+
@work(thread=True, exit_on_error=False)
|
|
338
|
+
def load_ec2(self):
|
|
339
|
+
try:
|
|
340
|
+
instances = get_running_ec2_instances(self.ec2)
|
|
341
|
+
except (BotoCoreError, ClientError) as e:
|
|
342
|
+
self.call_from_thread(self.show_error, 'ec2', e)
|
|
343
|
+
return
|
|
344
|
+
try:
|
|
345
|
+
vpc_names = get_vpc_names(self.ec2, sorted({vpc_id for i in instances if (vpc_id := i.get('VpcId'))}))
|
|
346
|
+
except (BotoCoreError, ClientError) as e:
|
|
347
|
+
logger.warning('Could not load VPC names: {!r}', e)
|
|
348
|
+
vpc_names = {}
|
|
349
|
+
self.call_from_thread(self.set_ec2_instances, sorted(instances, key=lambda i: ec2_name(i).lower()), vpc_names)
|
|
350
|
+
|
|
351
|
+
def set_ec2_instances(self, instances: list["EC2InstanceTypeDef"], vpc_names: dict[str, str] | None = None):
|
|
352
|
+
self.ec2_instances = instances
|
|
353
|
+
self.vpc_names = vpc_names or {}
|
|
354
|
+
self.query_one('#ec2-loading').display = False
|
|
355
|
+
self.filter_ec2()
|
|
356
|
+
|
|
357
|
+
def filter_ec2(self):
|
|
358
|
+
name_filter = self.query_one('#ec2-filter', Input).value.lower()
|
|
359
|
+
option_list = self.query_one('#ec2-list', OptionList)
|
|
360
|
+
option_list.clear_options()
|
|
361
|
+
self.query_one('#ec2-details', Static).update('')
|
|
362
|
+
option_list.add_options(
|
|
363
|
+
Option(ec2_label(i), id=i.get('InstanceId', '')) for i in self.ec2_instances if name_filter in ec2_name(i).lower()
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def ec2_instance(self, instance_id: str | None) -> "EC2InstanceTypeDef":
|
|
367
|
+
return next(i for i in self.ec2_instances if i.get('InstanceId', '') == instance_id)
|
|
368
|
+
|
|
369
|
+
def open_ec2_shell(self, instance: "EC2InstanceTypeDef", public: bool = False):
|
|
370
|
+
def resolve() -> Target:
|
|
371
|
+
target = ec2_target(self.ec2, instance)
|
|
372
|
+
return replace(target, ip=instance.get('PublicIpAddress', '')) if public else target
|
|
373
|
+
|
|
374
|
+
self.open_shell(f'{ec2_name(instance)} (public)' if public else ec2_name(instance), resolve)
|
|
375
|
+
|
|
376
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected):
|
|
377
|
+
self.open_ec2_shell(self.ec2_instance(event.option_id))
|
|
378
|
+
|
|
379
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted):
|
|
380
|
+
if not isinstance(event.option_list, EC2List):
|
|
381
|
+
return
|
|
382
|
+
instance = self.ec2_instance(event.option_id)
|
|
383
|
+
vpc_id = instance.get('VpcId', '')
|
|
384
|
+
fields = Table.grid(padding=(0, 1))
|
|
385
|
+
for label, value in (
|
|
386
|
+
(INSTANCE_TYPE_ICON, instance.get('InstanceType', '')),
|
|
387
|
+
('VPC:', self.vpc_names.get(vpc_id, vpc_id)),
|
|
388
|
+
(PRIVATE_IP_ICON, instance.get('PrivateIpAddress', '')),
|
|
389
|
+
(PUBLIC_IP_ICON, instance.get('PublicIpAddress', '')),
|
|
390
|
+
):
|
|
391
|
+
if value:
|
|
392
|
+
fields.add_row(label, value)
|
|
393
|
+
self.query_one('#ec2-details', Static).update(Group(Text(ec2_name(instance), style='bold'), fields))
|
|
394
|
+
|
|
395
|
+
def on_click(self, event: events.Click):
|
|
396
|
+
index = event.style.meta.get('option')
|
|
397
|
+
if event.button != RIGHT_MOUSE_BUTTON or not isinstance(event.widget, EC2List) or index is None:
|
|
398
|
+
return
|
|
399
|
+
instance = self.ec2_instance(event.widget.get_option_at_index(index).id)
|
|
400
|
+
options = [
|
|
401
|
+
Option('Connect via private IP', id='private'),
|
|
402
|
+
Option('Connect via public IP', id='public', disabled=not instance.get('PublicIpAddress')),
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
def on_choice(choice: str | None):
|
|
406
|
+
if choice:
|
|
407
|
+
self.open_ec2_shell(instance, public=choice == 'public')
|
|
408
|
+
|
|
409
|
+
self.push_screen(ContextMenu(event.screen_offset, options), on_choice)
|
|
410
|
+
|
|
411
|
+
# endregion - EC2
|
|
412
|
+
|
|
413
|
+
# region - EMR
|
|
414
|
+
@work(thread=True, exit_on_error=False)
|
|
415
|
+
def load_emr(self):
|
|
416
|
+
self.call_from_thread(self.set_emr_loading, 1)
|
|
417
|
+
try:
|
|
418
|
+
clusters = [Cluster(cluster_id, name) for cluster_id, name in get_emr_clusters(self.emr).values()]
|
|
419
|
+
except (BotoCoreError, ClientError) as e:
|
|
420
|
+
self.call_from_thread(self.show_error, 'emr', e)
|
|
421
|
+
return
|
|
422
|
+
finally:
|
|
423
|
+
self.call_from_thread(self.set_emr_loading, -1)
|
|
424
|
+
self.call_from_thread(self.set_clusters, sorted(clusters, key=lambda c: c.name.lower()))
|
|
425
|
+
|
|
426
|
+
def set_clusters(self, clusters: list[Cluster]):
|
|
427
|
+
self.clusters = clusters
|
|
428
|
+
self.filter_emr()
|
|
429
|
+
|
|
430
|
+
def filter_emr(self):
|
|
431
|
+
name_filter = self.query_one('#emr-filter', Input).value.lower()
|
|
432
|
+
tree = self.query_one('#emr-tree', Tree)
|
|
433
|
+
tree.clear()
|
|
434
|
+
for cluster in self.clusters:
|
|
435
|
+
if name_filter in cluster.name.lower():
|
|
436
|
+
node = tree.root.add(muted_label(cluster.name, cluster.id), data=cluster)
|
|
437
|
+
if cluster.id in self.cluster_instances:
|
|
438
|
+
self.add_groups(node, cluster.id)
|
|
439
|
+
|
|
440
|
+
def on_tree_node_expanded(self, event: Tree.NodeExpanded[Any]):
|
|
441
|
+
cluster = event.node.data
|
|
442
|
+
if isinstance(cluster, Cluster) and cluster.id not in self.cluster_instances and not event.node.children:
|
|
443
|
+
self.load_cluster_instances(cluster.id)
|
|
444
|
+
|
|
445
|
+
@work(thread=True, exit_on_error=False)
|
|
446
|
+
def load_cluster_instances(self, cluster_id: str):
|
|
447
|
+
self.call_from_thread(self.set_emr_loading, 1)
|
|
448
|
+
try:
|
|
449
|
+
groups = get_emr_instances(self.emr, cluster_id)
|
|
450
|
+
except (BotoCoreError, ClientError, ValueError) as e:
|
|
451
|
+
self.call_from_thread(self.show_cluster_error, cluster_id, e)
|
|
452
|
+
return
|
|
453
|
+
finally:
|
|
454
|
+
self.call_from_thread(self.set_emr_loading, -1)
|
|
455
|
+
self.call_from_thread(self.set_cluster_instances, cluster_id, groups)
|
|
456
|
+
|
|
457
|
+
def cluster_node(self, cluster_id: str) -> TreeNode[Any] | None:
|
|
458
|
+
tree = self.query_one('#emr-tree', Tree)
|
|
459
|
+
return next((n for n in tree.root.children if isinstance(n.data, Cluster) and n.data.id == cluster_id), None)
|
|
460
|
+
|
|
461
|
+
def show_cluster_error(self, cluster_id: str, error: Exception):
|
|
462
|
+
if node := self.cluster_node(cluster_id):
|
|
463
|
+
node.add_leaf(Text(str(error), style='red'))
|
|
464
|
+
|
|
465
|
+
def set_cluster_instances(self, cluster_id: str, groups: dict[str, list["EMRInstanceTypeDef"]]):
|
|
466
|
+
self.cluster_instances[cluster_id] = groups
|
|
467
|
+
if node := self.cluster_node(cluster_id):
|
|
468
|
+
self.add_groups(node, cluster_id)
|
|
469
|
+
|
|
470
|
+
def add_groups(self, node: TreeNode[Any], cluster_id: str):
|
|
471
|
+
groups = self.cluster_instances[cluster_id]
|
|
472
|
+
for group_name in sorted(groups, key=group_sort_key):
|
|
473
|
+
group = node.add(group_name, expand=True)
|
|
474
|
+
for instance in sorted(groups[group_name], key=lambda i: i.get('PrivateIpAddress', '')):
|
|
475
|
+
label = muted_label(instance.get('PrivateIpAddress', ''), instance.get('Ec2InstanceId', ''))
|
|
476
|
+
group.add_leaf(label, data=EMRNode(cluster_id, group_name, instance))
|
|
477
|
+
|
|
478
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected[Any]):
|
|
479
|
+
selected = event.node.data
|
|
480
|
+
if isinstance(selected, EMRNode):
|
|
481
|
+
title = f"{group_role(selected.group_name)} {selected.instance.get('PrivateIpAddress', '')}"
|
|
482
|
+
self.open_shell(
|
|
483
|
+
title,
|
|
484
|
+
lambda: emr_target(
|
|
485
|
+
self.emr,
|
|
486
|
+
selected.cluster_id,
|
|
487
|
+
selected.instance.get('Ec2InstanceId', ''),
|
|
488
|
+
selected.instance.get('PrivateIpAddress', ''),
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# endregion - EMR
|
|
@@ -1,68 +1,77 @@
|
|
|
1
|
-
from typing import TYPE_CHECKING
|
|
2
|
-
|
|
3
|
-
import questionary
|
|
4
|
-
|
|
5
|
-
from aws_ssh_utils.cli_utils import ShellError, spinner
|
|
6
|
-
from aws_ssh_utils.connection import Target, opkssh_provider
|
|
7
|
-
|
|
8
|
-
if TYPE_CHECKING:
|
|
9
|
-
from mypy_boto3_ec2 import EC2Client
|
|
10
|
-
from mypy_boto3_ec2.type_defs import InstanceTypeDef
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def get_running_ec2_instances(ec2: "EC2Client") -> list["InstanceTypeDef"]:
|
|
14
|
-
pages = ec2.get_paginator('describe_instances').paginate(
|
|
15
|
-
Filters=[{'Name': 'instance-state-name', 'Values': ['running']}],
|
|
16
|
-
)
|
|
17
|
-
return [i for page in pages for r in page['Reservations'] for i in r.get('Instances', [])]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
import questionary
|
|
4
|
+
|
|
5
|
+
from aws_ssh_utils.cli_utils import ShellError, spinner
|
|
6
|
+
from aws_ssh_utils.connection import Target, opkssh_provider
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from mypy_boto3_ec2 import EC2Client
|
|
10
|
+
from mypy_boto3_ec2.type_defs import InstanceTypeDef
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_running_ec2_instances(ec2: "EC2Client") -> list["InstanceTypeDef"]:
|
|
14
|
+
pages = ec2.get_paginator('describe_instances').paginate(
|
|
15
|
+
Filters=[{'Name': 'instance-state-name', 'Values': ['running']}],
|
|
16
|
+
)
|
|
17
|
+
return [i for page in pages for r in page['Reservations'] for i in r.get('Instances', [])]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_vpc_names(ec2: "EC2Client", vpc_ids: list[str]) -> dict[str, str]:
|
|
21
|
+
"""VPC id to its Name tag, or the id when there is none."""
|
|
22
|
+
vpcs = ec2.describe_vpcs(VpcIds=vpc_ids).get('Vpcs', []) if vpc_ids else []
|
|
23
|
+
return {
|
|
24
|
+
v.get('VpcId', ''): next((t.get('Value', '') for t in v.get('Tags', []) if t.get('Key') == 'Name'), v.get('VpcId', ''))
|
|
25
|
+
for v in vpcs
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ec2_name(instance: "InstanceTypeDef") -> str:
|
|
30
|
+
"""The Name tag, or the instance id when there is none."""
|
|
31
|
+
return next((t.get('Value', '') for t in instance.get('Tags', []) if t.get('Key') == 'Name'), instance.get('InstanceId', ''))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def guess_ec2_user(ec2: "EC2Client", instance: "InstanceTypeDef") -> str:
|
|
35
|
+
image_id = instance.get('ImageId')
|
|
36
|
+
images = ec2.describe_images(ImageIds=[image_id]).get('Images', []) if image_id else []
|
|
37
|
+
image_name = images[0].get('Name', '') if images else ''
|
|
38
|
+
return 'ubuntu' if 'ubuntu' in image_name.lower() else 'ec2-user'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ec2_target(ec2: "EC2Client", instance: "InstanceTypeDef", user: str | None = None) -> Target:
|
|
42
|
+
"""Connection target on the private IP. Guesses the user from the AMI name when not given."""
|
|
43
|
+
tags = {t.get('Key', '').lower(): t.get('Value', '') for t in instance.get('Tags', [])}
|
|
44
|
+
return Target(
|
|
45
|
+
instance_id=instance.get('InstanceId', ''),
|
|
46
|
+
ip=instance.get('PrivateIpAddress', ''),
|
|
47
|
+
user=user or guess_ec2_user(ec2, instance),
|
|
48
|
+
aliases=(ec2_name(instance),),
|
|
49
|
+
key_name=instance.get('KeyName'),
|
|
50
|
+
opkssh_provider=opkssh_provider(tags),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def prompt_for_ec2_instance(
|
|
55
|
+
ec2: "EC2Client",
|
|
56
|
+
prompt: str = 'Which EC2 instance do you want to connect to?',
|
|
57
|
+
) -> "InstanceTypeDef":
|
|
58
|
+
"""Asks for a name filter, then which of the matching running instances to use."""
|
|
59
|
+
with spinner():
|
|
60
|
+
instances = get_running_ec2_instances(ec2)
|
|
61
|
+
|
|
62
|
+
name_contains = questionary.text('Provide a name filter or leave blank to show all').unsafe_ask().lower()
|
|
63
|
+
|
|
64
|
+
by_display_name: dict[str, InstanceTypeDef] = {}
|
|
65
|
+
for instance in instances:
|
|
66
|
+
name = ec2_name(instance)
|
|
67
|
+
if name_contains and name_contains not in name.lower():
|
|
68
|
+
continue
|
|
69
|
+
# Disambiguate duplicate Name tags (e.g. ASG members) by appending the instance id
|
|
70
|
+
display = name if name not in by_display_name else f'{name} ({instance.get("InstanceId")})'
|
|
71
|
+
by_display_name[display] = instance
|
|
72
|
+
|
|
73
|
+
if not by_display_name:
|
|
74
|
+
raise ShellError('No matching EC2 instances found!')
|
|
75
|
+
|
|
76
|
+
choice = questionary.select(prompt, choices=sorted(by_display_name)).unsafe_ask()
|
|
77
|
+
return by_display_name[choice]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|