aws-ssh-utils 0.3.2__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.2 → 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.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/app.py +492 -380
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ec2_utils.py +77 -68
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/terminal.py +60 -5
- aws_ssh_utils-0.3.2/aws_ssh_utils/__init__.py +0 -1
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/.gitignore +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/LICENSE +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/README.md +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/cli_utils.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/__init__.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/ec2.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr_all.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/healthcheck.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/connection.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/emr_utils.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/logging_utils.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/py.typed +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/session.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ssh.py +0 -0
- {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/pyproject.toml +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -1,380 +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
|
-
def
|
|
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.query_one(
|
|
225
|
-
self.query_one('
|
|
226
|
-
self.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
self.
|
|
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
|
-
def
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
self.call_from_thread(self.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
self.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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,10 +1,12 @@
|
|
|
1
1
|
"""Textual widgets rendering SSH and SSM shell channels through the pyte VT100 emulator."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import datetime as dt
|
|
4
5
|
import time
|
|
5
6
|
from collections import deque
|
|
6
7
|
from collections.abc import Sequence
|
|
7
8
|
from functools import lru_cache
|
|
9
|
+
from importlib.metadata import version
|
|
8
10
|
from typing import Any
|
|
9
11
|
|
|
10
12
|
import pyte
|
|
@@ -228,17 +230,45 @@ class Terminal(Widget, can_focus=True):
|
|
|
228
230
|
event.prevent_default()
|
|
229
231
|
self.send(data)
|
|
230
232
|
|
|
231
|
-
def on_paste(self, event: events.Paste):
|
|
233
|
+
async def on_paste(self, event: events.Paste):
|
|
234
|
+
event.stop()
|
|
232
235
|
text = event.text
|
|
236
|
+
started = time.monotonic()
|
|
237
|
+
logger.bind(file_only=True).info(
|
|
238
|
+
'Paste shell received: chars={}; bracketed={}',
|
|
239
|
+
len(text),
|
|
240
|
+
BRACKETED_PASTE in self.vt.mode,
|
|
241
|
+
)
|
|
233
242
|
if BRACKETED_PASTE in self.vt.mode:
|
|
234
243
|
text = f'\x1b[200~{text}\x1b[201~'
|
|
235
|
-
self.
|
|
244
|
+
if self.scrolled:
|
|
245
|
+
self.scroll_history(-self.scrolled)
|
|
246
|
+
try:
|
|
247
|
+
# SSH flow control may block a large paste. Keep the app loop free
|
|
248
|
+
# so the reader can deliver output while input is still being sent.
|
|
249
|
+
await asyncio.to_thread(self._send_all, text.encode())
|
|
250
|
+
logger.bind(file_only=True).info(
|
|
251
|
+
'Paste shell sent: bytes={}; elapsed={:.3f}s',
|
|
252
|
+
len(text.encode()),
|
|
253
|
+
time.monotonic() - started,
|
|
254
|
+
)
|
|
255
|
+
except OSError:
|
|
256
|
+
logger.exception('Terminal paste failed')
|
|
257
|
+
self.post_message(self.Closed())
|
|
236
258
|
|
|
237
259
|
def send(self, text: str):
|
|
238
260
|
"""Send user input, jumping back to the live screen like other terminals do."""
|
|
239
261
|
if self.scrolled:
|
|
240
262
|
self.scroll_history(-self.scrolled)
|
|
241
|
-
self.
|
|
263
|
+
self._send_all(text.encode())
|
|
264
|
+
|
|
265
|
+
def _send_all(self, data: bytes):
|
|
266
|
+
"""A channel write may accept only one SSH packet or the available window."""
|
|
267
|
+
while data:
|
|
268
|
+
sent = self.channel.send(data)
|
|
269
|
+
if sent <= 0:
|
|
270
|
+
raise OSError('Terminal channel closed while sending input')
|
|
271
|
+
data = data[sent:]
|
|
242
272
|
|
|
243
273
|
@override
|
|
244
274
|
def render_line(self, y: int) -> Strip:
|
|
@@ -301,12 +331,37 @@ class ShellStatus(Horizontal):
|
|
|
301
331
|
self.query_one('.duration', Static).update(f"{hours:02}:{minutes:02}:{seconds:02}")
|
|
302
332
|
|
|
303
333
|
|
|
304
|
-
class
|
|
305
|
-
"""
|
|
334
|
+
class TerminalApp(LoggedApp):
|
|
335
|
+
"""Shared input handling for apps containing interactive shell terminals."""
|
|
306
336
|
|
|
307
337
|
# Its priority ctrl+p binding would steal shell history navigation from the terminal.
|
|
308
338
|
ENABLE_COMMAND_PALETTE = False
|
|
309
339
|
|
|
340
|
+
def on_mount(self):
|
|
341
|
+
logger.bind(file_only=True).info('Terminal input diagnostics v1; textual={}', version('textual'))
|
|
342
|
+
|
|
343
|
+
@override
|
|
344
|
+
async def on_event(self, event: events.Event) -> None:
|
|
345
|
+
if isinstance(event, (events.AppFocus, events.AppBlur)):
|
|
346
|
+
logger.bind(file_only=True).info('Terminal focus event: {}', type(event).__name__)
|
|
347
|
+
if isinstance(event, events.Paste) and not event.is_forwarded and not self.app_focus:
|
|
348
|
+
# A terminal's paste confirmation dialog can send the paste before
|
|
349
|
+
# FocusIn. Textual restores the previous widget for keys/mouse, but
|
|
350
|
+
# not pastes, so they otherwise go to an unfocused screen and vanish.
|
|
351
|
+
self.app_focus = True
|
|
352
|
+
if isinstance(event, events.Paste) and not event.is_forwarded:
|
|
353
|
+
logger.bind(file_only=True).info(
|
|
354
|
+
'Paste app received: chars={}; app_focus={}; target={}',
|
|
355
|
+
len(event.text),
|
|
356
|
+
self.app_focus,
|
|
357
|
+
type(self.focused).__name__,
|
|
358
|
+
)
|
|
359
|
+
await super().on_event(event)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class ShellApp(TerminalApp):
|
|
363
|
+
"""A single full-screen shell, exits when the shell closes."""
|
|
364
|
+
|
|
310
365
|
def __init__(self, via: str, client: ShellClient, channel: ShellChannel, scrollback: int = 0):
|
|
311
366
|
super().__init__()
|
|
312
367
|
self.via = via
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.2"
|
|
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
|