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.
Files changed (22) hide show
  1. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/PKG-INFO +1 -1
  2. aws_ssh_utils-0.4.0/aws_ssh_utils/__init__.py +1 -0
  3. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/app.py +492 -378
  4. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ec2_utils.py +77 -68
  5. aws_ssh_utils-0.3.3/aws_ssh_utils/__init__.py +0 -1
  6. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/.gitignore +0 -0
  7. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/LICENSE +0 -0
  8. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/README.md +0 -0
  9. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/cli_utils.py +0 -0
  10. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/__init__.py +0 -0
  11. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/ec2.py +0 -0
  12. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr.py +0 -0
  13. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr_all.py +0 -0
  14. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/healthcheck.py +0 -0
  15. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/connection.py +0 -0
  16. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/emr_utils.py +0 -0
  17. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/logging_utils.py +0 -0
  18. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/py.typed +0 -0
  19. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/session.py +0 -0
  20. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ssh.py +0 -0
  21. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/aws_ssh_utils/terminal.py +0 -0
  22. {aws_ssh_utils-0.3.3 → aws_ssh_utils-0.4.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aws-ssh-utils
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Easy AWS SSHing
5
5
  Project-URL: Homepage, https://github.com/mvanderlee/aws-ssh-utils
6
6
  Author-email: Michiel Vanderlee <jmt.vanderlee@gmail.com>
@@ -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.text import Text
16
- from textual import work
17
- from textual.app import ComposeResult
18
- from textual.binding import Binding, BindingType
19
- from textual.containers import Horizontal
20
- from textual.widgets import Footer, Input, OptionList, RichLog, Static, TabbedContent, TabPane, Tree
21
- from textual.widgets.option_list import Option
22
- from textual.widgets.tree import TreeNode
23
-
24
- from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, render_attempts, scrollback_option
25
- from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
26
- from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances
27
- from aws_ssh_utils.emr_utils import emr_target, get_emr_clusters, get_emr_instances, group_role, group_sort_key
28
- from aws_ssh_utils.logging_utils import terminal_logging
29
- from aws_ssh_utils.terminal import ShellStatus, Terminal, TerminalApp
30
-
31
- if TYPE_CHECKING:
32
- from mypy_boto3_ec2 import EC2Client
33
- from mypy_boto3_ec2.type_defs import InstanceTypeDef as EC2InstanceTypeDef
34
- from mypy_boto3_emr import EMRClient
35
- from mypy_boto3_emr.type_defs import InstanceTypeDef as EMRInstanceTypeDef
36
-
37
- Status = Literal['connecting', 'waiting', 'connected', 'failed']
38
- STATUS_ICONS: dict[Status, Text] = {
39
- 'waiting': Text('⚠', style='yellow'),
40
- 'connected': Text('●', style='green'),
41
- 'failed': Text('●', style='red'),
42
- }
43
- ANIMATION_INTERVAL = 1 / 12
44
-
45
-
46
- @click.command("app")
47
- @click.option('-p', '--profile', default=None, help='Which AWS profile to use')
48
- @click.option('-r', '--region', default=None, help='Which AWS region to use')
49
- @scrollback_option
50
- def app(profile: str | None = None, region: str | None = None, scrollback: int | None = None, **kwargs: Any):
51
- """Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs."""
52
- try:
53
- session = boto3.Session(profile_name=profile, region_name=region)
54
- session.client('sts').get_caller_identity()
55
- except (BotoCoreError, ClientError) as e:
56
- logger.bind(file_only=True).opt(exception=e).error('AWS authentication failed')
57
- Console().print(f"[red]✗[/] {escape(str(e))}")
58
- sys.exit(1)
59
-
60
- env = detect_environment(profile, session.region_name)
61
- scrollback = detect_scrollback() if scrollback is None else scrollback
62
- with terminal_logging():
63
- SSHApp(session.client('ec2'), session.client('emr'), env, scrollback).run()
64
-
65
-
66
- def muted_label(name: str, instance_id: str) -> Text:
67
- return Text.assemble(name, (f" - {instance_id}", 'dim'))
68
-
69
-
70
- @dataclass(frozen=True)
71
- class Cluster:
72
- id: str
73
- name: str
74
-
75
-
76
- @dataclass(frozen=True)
77
- class EMRNode:
78
- cluster_id: str
79
- group_name: str
80
- instance: "EMRInstanceTypeDef"
81
-
82
-
83
- class BouncingBall(Static):
84
- def on_mount(self):
85
- spinner = Spinner('bouncingBall')
86
- self.set_interval(ANIMATION_INTERVAL, lambda: self.update(spinner.render(time.monotonic())))
87
-
88
-
89
- class ShellPane(TabPane):
90
- """Connects in the background, then swaps its log for a Terminal."""
91
-
92
- def __init__(self, title: str, resolve: Callable[[], Target], env: Environment, scrollback: int, **kwargs: Any):
93
- super().__init__(title, **kwargs)
94
- self.title_ = title
95
- self.resolve = resolve
96
- self.env = env
97
- self.scrollback = scrollback
98
- self.status: Status = 'connecting'
99
- self.spinner = Spinner('dots')
100
- self.attempts: list[Attempt] = []
101
-
102
- def compose(self) -> ComposeResult:
103
- yield Static(classes='attempts')
104
- yield RichLog(wrap=True, markup=True)
105
-
106
- def on_mount(self):
107
- self.animation = self.set_interval(ANIMATION_INTERVAL, self.update_label)
108
- self.connect()
109
-
110
- def update_label(self):
111
- icon = cast(Text, self.spinner.render(time.monotonic())) if self.status == 'connecting' else STATUS_ICONS[self.status]
112
- self.query_ancestor(TabbedContent).get_tab(self).label = Text.assemble(icon, ' ', self.title_)
113
-
114
- def set_attempts(self, attempts: list[Attempt]):
115
- self.attempts = attempts
116
- self.query_one('.attempts', Static).update(render_attempts(self.attempts))
117
- self.update_label()
118
-
119
- def set_status(self, status: Status):
120
- self.status = status
121
- if status != 'connecting':
122
- self.animation.stop()
123
- self.update_label()
124
-
125
- def write(self, markup: str):
126
- self.query_one(RichLog).write(markup)
127
-
128
- @work(thread=True, exit_on_error=False)
129
- def connect(self):
130
- def write(line: str):
131
- self.app.call_from_thread(self.write, escape(line))
132
-
133
- try:
134
- result = connect(
135
- self.resolve(),
136
- self.env,
137
- on_output=write,
138
- on_waiting=lambda: self.app.call_from_thread(self.set_status, 'waiting'),
139
- on_progress=lambda attempts: self.app.call_from_thread(self.set_attempts, attempts),
140
- )
141
- if result is None:
142
- self.app.call_from_thread(self.set_status, 'failed')
143
- return
144
-
145
- channel = open_shell_channel(result.client)
146
- except Exception as e:
147
- logger.exception("Connection failed")
148
- self.app.call_from_thread(self.write, f"[red]✗ {escape(str(e) or type(e).__name__)}[/]")
149
- self.app.call_from_thread(self.set_status, 'failed')
150
- return
151
-
152
- self.app.call_from_thread(self.attach, result.via, Terminal(result.client, channel, self.scrollback))
153
-
154
- async def attach(self, via: str, terminal: Terminal):
155
- logger.info('Shell tab {} connected via {}', self.id, via)
156
- # Stop animation before yielding to widget removal or mounting a reader
157
- # that may immediately close the session.
158
- self.set_status('connected')
159
- await self.query_one(RichLog).remove()
160
- await self.query_one('.attempts').remove()
161
- await self.mount(ShellStatus(via), terminal)
162
-
163
- def on_terminal_closed(self):
164
- logger.info('Shell tab {} closed', self.id)
165
- self.query_ancestor(TabbedContent).remove_pane(self.id or '')
166
-
167
-
168
- class SSHApp(TerminalApp):
169
- """The textual app"""
170
-
171
- TITLE = "AWS SSH"
172
- CSS = """
173
- #sidebar { width: 60; }
174
- #shells { width: 1fr; }
175
- Footer { background: $surface; }
176
- TabbedContent, TabbedContent > ContentSwitcher, TabPane { height: 1fr; }
177
- #ec2-list, #emr-tree { height: 1fr; }
178
- #emr-tree {
179
- border: tall $border-blurred;
180
- padding: 0 1;
181
- background: $surface;
182
- &:focus { border: tall $border; }
183
- }
184
- .error { color: $error; }
185
- """
186
- BINDINGS: ClassVar[list[BindingType]] = [
187
- Binding('ctrl+q', 'quit', 'Quit', priority=True),
188
- Binding('f12', 'toggle_sidebar', 'Toggle sidebar', priority=True),
189
- Binding('ctrl+w', 'close_tab', 'Close tab'),
190
- ]
191
-
192
- def __init__(self, ec2: "EC2Client", emr: "EMRClient", env: Environment, scrollback: int):
193
- super().__init__()
194
- self.ec2 = ec2
195
- self.emr = emr
196
- self.env = env
197
- self.scrollback = scrollback
198
- self.ec2_instances: list[EC2InstanceTypeDef] = []
199
- self.clusters: list[Cluster] = []
200
- self.cluster_instances: dict[str, dict[str, list[EMRInstanceTypeDef]]] = {}
201
- self.emr_loading = 0
202
- self.pane_ids = count()
203
-
204
- def compose(self) -> ComposeResult:
205
- with Horizontal():
206
- with TabbedContent(id='sidebar'):
207
- with TabPane('EC2'):
208
- yield Input(placeholder='Filter by name', id='ec2-filter')
209
- yield BouncingBall(id='ec2-loading')
210
- yield Static(id='ec2-error', classes='error')
211
- yield OptionList(id='ec2-list')
212
- with TabPane('EMR'):
213
- yield Input(placeholder='Filter by name', id='emr-filter')
214
- yield BouncingBall(id='emr-loading')
215
- yield Static(id='emr-error', classes='error')
216
- yield Tree('Clusters', id='emr-tree')
217
- yield TabbedContent(id='shells')
218
- yield Footer()
219
-
220
- def on_mount(self):
221
- self.query_one('#ec2-error').display = False
222
- self.query_one('#emr-error').display = False
223
- self.query_one('#emr-tree', Tree).show_root = False
224
- self.load_ec2()
225
- self.load_emr()
226
-
227
- def show_error(self, prefix: str, error: Exception):
228
- self.query_one(f'#{prefix}-loading').display = False
229
- error_widget = self.query_one(f'#{prefix}-error', Static)
230
- error_widget.update(Text(str(error)))
231
- error_widget.display = True
232
-
233
- def set_emr_loading(self, delta: int):
234
- self.emr_loading += delta
235
- self.query_one('#emr-loading').display = self.emr_loading > 0
236
-
237
- def on_input_changed(self, event: Input.Changed):
238
- if event.input.id == 'ec2-filter':
239
- self.filter_ec2()
240
- else:
241
- self.filter_emr()
242
-
243
- def action_toggle_sidebar(self):
244
- sidebar = self.query_one('#sidebar')
245
- sidebar.display = not sidebar.display
246
- if not sidebar.display and self.focused and sidebar in self.focused.ancestors_with_self:
247
- pane = self.query_one('#shells', TabbedContent).active_pane
248
- self.set_focus(pane.query(Terminal).first() if pane and pane.query(Terminal) else None)
249
-
250
- def action_close_tab(self):
251
- shells = self.query_one('#shells', TabbedContent)
252
- if shells.active:
253
- shells.remove_pane(shells.active)
254
-
255
- def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated):
256
- if event.tabbed_content.id != 'shells' or event.pane.id != event.tabbed_content.active:
257
- return
258
- if terminals := event.pane.query(Terminal):
259
- terminals.first().focus()
260
-
261
- def open_shell(self, title: str, resolve: Callable[[], Target]):
262
- shells = self.query_one('#shells', TabbedContent)
263
- pane = ShellPane(title, resolve, self.env, self.scrollback, id=f'shell-{next(self.pane_ids)}')
264
- logger.info('Opening shell tab {} ({})', pane.id, title)
265
- shells.add_pane(pane)
266
- shells.active = pane.id or ''
267
-
268
- # region - EC2
269
- @work(thread=True, exit_on_error=False)
270
- def load_ec2(self):
271
- try:
272
- instances = get_running_ec2_instances(self.ec2)
273
- except (BotoCoreError, ClientError) as e:
274
- self.call_from_thread(self.show_error, 'ec2', e)
275
- return
276
- self.call_from_thread(self.set_ec2_instances, sorted(instances, key=lambda i: ec2_name(i).lower()))
277
-
278
- def set_ec2_instances(self, instances: list["EC2InstanceTypeDef"]):
279
- self.ec2_instances = instances
280
- self.query_one('#ec2-loading').display = False
281
- self.filter_ec2()
282
-
283
- def filter_ec2(self):
284
- name_filter = self.query_one('#ec2-filter', Input).value.lower()
285
- option_list = self.query_one('#ec2-list', OptionList)
286
- option_list.clear_options()
287
- option_list.add_options(
288
- Option(muted_label(ec2_name(i), i.get('InstanceId', '')), id=i.get('InstanceId', ''))
289
- for i in self.ec2_instances
290
- if name_filter in ec2_name(i).lower()
291
- )
292
-
293
- def on_option_list_option_selected(self, event: OptionList.OptionSelected):
294
- instance = next(i for i in self.ec2_instances if i.get('InstanceId', '') == event.option_id)
295
- self.open_shell(ec2_name(instance), lambda: ec2_target(self.ec2, instance))
296
-
297
- # endregion - EC2
298
-
299
- # region - EMR
300
- @work(thread=True, exit_on_error=False)
301
- def load_emr(self):
302
- self.call_from_thread(self.set_emr_loading, 1)
303
- try:
304
- clusters = [Cluster(cluster_id, name) for cluster_id, name in get_emr_clusters(self.emr).values()]
305
- except (BotoCoreError, ClientError) as e:
306
- self.call_from_thread(self.show_error, 'emr', e)
307
- return
308
- finally:
309
- self.call_from_thread(self.set_emr_loading, -1)
310
- self.call_from_thread(self.set_clusters, sorted(clusters, key=lambda c: c.name.lower()))
311
-
312
- def set_clusters(self, clusters: list[Cluster]):
313
- self.clusters = clusters
314
- self.filter_emr()
315
-
316
- def filter_emr(self):
317
- name_filter = self.query_one('#emr-filter', Input).value.lower()
318
- tree = self.query_one('#emr-tree', Tree)
319
- tree.clear()
320
- for cluster in self.clusters:
321
- if name_filter in cluster.name.lower():
322
- node = tree.root.add(muted_label(cluster.name, cluster.id), data=cluster)
323
- if cluster.id in self.cluster_instances:
324
- self.add_groups(node, cluster.id)
325
-
326
- def on_tree_node_expanded(self, event: Tree.NodeExpanded[Any]):
327
- cluster = event.node.data
328
- if isinstance(cluster, Cluster) and cluster.id not in self.cluster_instances and not event.node.children:
329
- self.load_cluster_instances(cluster.id)
330
-
331
- @work(thread=True, exit_on_error=False)
332
- def load_cluster_instances(self, cluster_id: str):
333
- self.call_from_thread(self.set_emr_loading, 1)
334
- try:
335
- groups = get_emr_instances(self.emr, cluster_id)
336
- except (BotoCoreError, ClientError, ValueError) as e:
337
- self.call_from_thread(self.show_cluster_error, cluster_id, e)
338
- return
339
- finally:
340
- self.call_from_thread(self.set_emr_loading, -1)
341
- self.call_from_thread(self.set_cluster_instances, cluster_id, groups)
342
-
343
- def cluster_node(self, cluster_id: str) -> TreeNode[Any] | None:
344
- tree = self.query_one('#emr-tree', Tree)
345
- return next((n for n in tree.root.children if isinstance(n.data, Cluster) and n.data.id == cluster_id), None)
346
-
347
- def show_cluster_error(self, cluster_id: str, error: Exception):
348
- if node := self.cluster_node(cluster_id):
349
- node.add_leaf(Text(str(error), style='red'))
350
-
351
- def set_cluster_instances(self, cluster_id: str, groups: dict[str, list["EMRInstanceTypeDef"]]):
352
- self.cluster_instances[cluster_id] = groups
353
- if node := self.cluster_node(cluster_id):
354
- self.add_groups(node, cluster_id)
355
-
356
- def add_groups(self, node: TreeNode[Any], cluster_id: str):
357
- groups = self.cluster_instances[cluster_id]
358
- for group_name in sorted(groups, key=group_sort_key):
359
- group = node.add(group_name, expand=True)
360
- for instance in sorted(groups[group_name], key=lambda i: i.get('PrivateIpAddress', '')):
361
- label = muted_label(instance.get('PrivateIpAddress', ''), instance.get('Ec2InstanceId', ''))
362
- group.add_leaf(label, data=EMRNode(cluster_id, group_name, instance))
363
-
364
- def on_tree_node_selected(self, event: Tree.NodeSelected[Any]):
365
- selected = event.node.data
366
- if isinstance(selected, EMRNode):
367
- title = f"{group_role(selected.group_name)} {selected.instance.get('PrivateIpAddress', '')}"
368
- self.open_shell(
369
- title,
370
- lambda: emr_target(
371
- self.emr,
372
- selected.cluster_id,
373
- selected.instance.get('Ec2InstanceId', ''),
374
- selected.instance.get('PrivateIpAddress', ''),
375
- ),
376
- )
377
-
378
- # endregion - EMR
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 ec2_name(instance: "InstanceTypeDef") -> str:
21
- """The Name tag, or the instance id when there is none."""
22
- return next((t.get('Value', '') for t in instance.get('Tags', []) if t.get('Key') == 'Name'), instance.get('InstanceId', ''))
23
-
24
-
25
- def guess_ec2_user(ec2: "EC2Client", instance: "InstanceTypeDef") -> str:
26
- image_id = instance.get('ImageId')
27
- images = ec2.describe_images(ImageIds=[image_id]).get('Images', []) if image_id else []
28
- image_name = images[0].get('Name', '') if images else ''
29
- return 'ubuntu' if 'ubuntu' in image_name.lower() else 'ec2-user'
30
-
31
-
32
- def ec2_target(ec2: "EC2Client", instance: "InstanceTypeDef", user: str | None = None) -> Target:
33
- """Connection target on the private IP. Guesses the user from the AMI name when not given."""
34
- tags = {t.get('Key', '').lower(): t.get('Value', '') for t in instance.get('Tags', [])}
35
- return Target(
36
- instance_id=instance.get('InstanceId', ''),
37
- ip=instance.get('PrivateIpAddress', ''),
38
- user=user or guess_ec2_user(ec2, instance),
39
- aliases=(ec2_name(instance),),
40
- key_name=instance.get('KeyName'),
41
- opkssh_provider=opkssh_provider(tags),
42
- )
43
-
44
-
45
- def prompt_for_ec2_instance(
46
- ec2: "EC2Client",
47
- prompt: str = 'Which EC2 instance do you want to connect to?',
48
- ) -> "InstanceTypeDef":
49
- """Asks for a name filter, then which of the matching running instances to use."""
50
- with spinner():
51
- instances = get_running_ec2_instances(ec2)
52
-
53
- name_contains = questionary.text('Provide a name filter or leave blank to show all').unsafe_ask().lower()
54
-
55
- by_display_name: dict[str, InstanceTypeDef] = {}
56
- for instance in instances:
57
- name = ec2_name(instance)
58
- if name_contains and name_contains not in name.lower():
59
- continue
60
- # Disambiguate duplicate Name tags (e.g. ASG members) by appending the instance id
61
- display = name if name not in by_display_name else f'{name} ({instance.get("InstanceId")})'
62
- by_display_name[display] = instance
63
-
64
- if not by_display_name:
65
- raise ShellError('No matching EC2 instances found!')
66
-
67
- choice = questionary.select(prompt, choices=sorted(by_display_name)).unsafe_ask()
68
- return by_display_name[choice]
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