aws-ssh-utils 0.2.7__tar.gz → 0.3.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.2.7 → aws_ssh_utils-0.3.0}/PKG-INFO +13 -7
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/README.md +10 -6
- aws_ssh_utils-0.3.0/aws_ssh_utils/__init__.py +1 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/commands/app.py +1 -1
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/commands/ec2.py +1 -1
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/commands/emr.py +5 -3
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/connection.py +41 -38
- aws_ssh_utils-0.3.0/aws_ssh_utils/session.py +128 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/terminal.py +6 -5
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/pyproject.toml +2 -0
- aws_ssh_utils-0.2.7/aws_ssh_utils/__init__.py +0 -1
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/.gitignore +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/LICENSE +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/cli_utils.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/commands/__init__.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/commands/emr_all.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/commands/healthcheck.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/ec2_utils.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/emr_utils.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/py.typed +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.0}/aws_ssh_utils/ssh.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aws-ssh-utils
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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>
|
|
@@ -26,7 +26,9 @@ Requires-Dist: boto3
|
|
|
26
26
|
Requires-Dist: click
|
|
27
27
|
Requires-Dist: loguru
|
|
28
28
|
Requires-Dist: paramiko
|
|
29
|
+
Requires-Dist: ptyprocess>=0.7.0; sys_platform != 'win32'
|
|
29
30
|
Requires-Dist: pyte>=0.8.2
|
|
31
|
+
Requires-Dist: pywinpty>=3.0.0; sys_platform == 'win32'
|
|
30
32
|
Requires-Dist: questionary
|
|
31
33
|
Requires-Dist: requests>=2.34.2
|
|
32
34
|
Requires-Dist: rich>=15.0.0
|
|
@@ -58,17 +60,21 @@ It utilizes [questionary](https://pypi.org/project/questionary/) to ask you whic
|
|
|
58
60
|
|
|
59
61
|
## App
|
|
60
62
|
|
|
61
|
-
Terminal UI to browse EC2 instances and EMR clusters, with SSH shells in tabs.
|
|
63
|
+
Terminal UI to browse EC2 instances and EMR clusters, with SSM or SSH shells in tabs.
|
|
62
64
|
It's the default command, so `aws_ssh` and `aws_ssh -p my-profile` launch it too.
|
|
63
|
-
Each connection tries, in order: `~/.ssh/config`,
|
|
65
|
+
Each connection tries, in order: `~/.ssh/config`, a keyless AWS SSM shell, opkssh (via instance/cluster tags),
|
|
64
66
|
the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
|
|
65
|
-
|
|
67
|
+
SSM uses `aws ssm start-session --target INSTANCE_ID` with the selected profile and region. It requires the AWS CLI,
|
|
68
|
+
`session-manager-plugin`, and Session Manager access to the instance, but no SSH key, SSH server, or inbound port 22.
|
|
69
|
+
The shell runs as the Session Manager configured user (usually `ssm-user`); SSH usernames and key options only apply
|
|
70
|
+
to SSH connections. The `ec2` and `emr` commands use the same connection order; `emr-all` still uses SSH in tmux.
|
|
71
|
+
For SSH connections, unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
|
|
66
72
|
|
|
67
73
|
```shell
|
|
68
74
|
$ aws_ssh app --help
|
|
69
75
|
Usage: aws_ssh app [OPTIONS]
|
|
70
76
|
|
|
71
|
-
Browse EC2 instances and EMR clusters, and open SSH shells in tabs.
|
|
77
|
+
Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs.
|
|
72
78
|
|
|
73
79
|
Options:
|
|
74
80
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -92,7 +98,7 @@ $ aws_auth ec2 --help
|
|
|
92
98
|
Usage: aws_ssh ec2 [OPTIONS]
|
|
93
99
|
|
|
94
100
|
Asks user which EC2 instance they want to connect to, then opens an
|
|
95
|
-
interactive SSH session to the instance
|
|
101
|
+
interactive SSM or SSH session to the instance
|
|
96
102
|
|
|
97
103
|
Options:
|
|
98
104
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -114,7 +120,7 @@ Select the EMR cluster, instance group, and instance to connect to.
|
|
|
114
120
|
$ aws_ssh emr --help
|
|
115
121
|
Usage: aws_ssh emr [OPTIONS]
|
|
116
122
|
|
|
117
|
-
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSH session to the instance
|
|
123
|
+
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSM or SSH session to the instance
|
|
118
124
|
|
|
119
125
|
Options:
|
|
120
126
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -22,17 +22,21 @@ It utilizes [questionary](https://pypi.org/project/questionary/) to ask you whic
|
|
|
22
22
|
|
|
23
23
|
## App
|
|
24
24
|
|
|
25
|
-
Terminal UI to browse EC2 instances and EMR clusters, with SSH shells in tabs.
|
|
25
|
+
Terminal UI to browse EC2 instances and EMR clusters, with SSM or SSH shells in tabs.
|
|
26
26
|
It's the default command, so `aws_ssh` and `aws_ssh -p my-profile` launch it too.
|
|
27
|
-
Each connection tries, in order: `~/.ssh/config`,
|
|
27
|
+
Each connection tries, in order: `~/.ssh/config`, a keyless AWS SSM shell, opkssh (via instance/cluster tags),
|
|
28
28
|
the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
|
|
29
|
-
|
|
29
|
+
SSM uses `aws ssm start-session --target INSTANCE_ID` with the selected profile and region. It requires the AWS CLI,
|
|
30
|
+
`session-manager-plugin`, and Session Manager access to the instance, but no SSH key, SSH server, or inbound port 22.
|
|
31
|
+
The shell runs as the Session Manager configured user (usually `ssm-user`); SSH usernames and key options only apply
|
|
32
|
+
to SSH connections. The `ec2` and `emr` commands use the same connection order; `emr-all` still uses SSH in tmux.
|
|
33
|
+
For SSH connections, unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
|
|
30
34
|
|
|
31
35
|
```shell
|
|
32
36
|
$ aws_ssh app --help
|
|
33
37
|
Usage: aws_ssh app [OPTIONS]
|
|
34
38
|
|
|
35
|
-
Browse EC2 instances and EMR clusters, and open SSH shells in tabs.
|
|
39
|
+
Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs.
|
|
36
40
|
|
|
37
41
|
Options:
|
|
38
42
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -56,7 +60,7 @@ $ aws_auth ec2 --help
|
|
|
56
60
|
Usage: aws_ssh ec2 [OPTIONS]
|
|
57
61
|
|
|
58
62
|
Asks user which EC2 instance they want to connect to, then opens an
|
|
59
|
-
interactive SSH session to the instance
|
|
63
|
+
interactive SSM or SSH session to the instance
|
|
60
64
|
|
|
61
65
|
Options:
|
|
62
66
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -78,7 +82,7 @@ Select the EMR cluster, instance group, and instance to connect to.
|
|
|
78
82
|
$ aws_ssh emr --help
|
|
79
83
|
Usage: aws_ssh emr [OPTIONS]
|
|
80
84
|
|
|
81
|
-
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSH session to the instance
|
|
85
|
+
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSM or SSH session to the instance
|
|
82
86
|
|
|
83
87
|
Options:
|
|
84
88
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -48,7 +48,7 @@ ANIMATION_INTERVAL = 1 / 12
|
|
|
48
48
|
@click.option('-r', '--region', default=None, help='Which AWS region to use')
|
|
49
49
|
@scrollback_option
|
|
50
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 SSH shells in tabs."""
|
|
51
|
+
"""Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs."""
|
|
52
52
|
try:
|
|
53
53
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
54
54
|
session.client('sts').get_caller_identity()
|
|
@@ -41,7 +41,7 @@ def ec2(
|
|
|
41
41
|
):
|
|
42
42
|
"""
|
|
43
43
|
Asks user which EC2 instance they want to connect to,
|
|
44
|
-
then opens an interactive SSH session to the instance
|
|
44
|
+
then opens an interactive SSM or SSH session to the instance
|
|
45
45
|
"""
|
|
46
46
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
47
47
|
ec2_client = session.client('ec2')
|
|
@@ -45,7 +45,7 @@ def emr(
|
|
|
45
45
|
):
|
|
46
46
|
"""
|
|
47
47
|
Asks user which Cluster and EC2 instance they want to connect to,
|
|
48
|
-
then opens an interactive SSH session to the instance
|
|
48
|
+
then opens an interactive SSM or SSH session to the instance
|
|
49
49
|
"""
|
|
50
50
|
session = boto3.Session(profile_name=profile, region_name=region)
|
|
51
51
|
emr_client = session.client('emr')
|
|
@@ -65,9 +65,11 @@ def emr(
|
|
|
65
65
|
with spinner():
|
|
66
66
|
target = emr_target(emr_client, cluster_id, instance_ip.instance_id or '', ip)
|
|
67
67
|
target = replace(target, user=user or target.user, key_file=key_file)
|
|
68
|
+
env = detect_environment(profile, session.region_name)
|
|
68
69
|
|
|
69
70
|
if (
|
|
70
|
-
|
|
71
|
+
not env.ssm_installed
|
|
72
|
+
and key_file is None
|
|
71
73
|
and target.opkssh_provider is None
|
|
72
74
|
and find_ssh_key_file(target.key_name) is None
|
|
73
75
|
and not questionary.confirm(f'Could not find the ssh key {target.key_name}, would you like to continue?').unsafe_ask()
|
|
@@ -78,7 +80,7 @@ def emr(
|
|
|
78
80
|
postfix = f'[{group_idx}]' if group_idx else ''
|
|
79
81
|
open_shell(
|
|
80
82
|
target,
|
|
81
|
-
|
|
83
|
+
env,
|
|
82
84
|
title=f'{cluster_name} - {group}{postfix}',
|
|
83
85
|
scrollback=scrollback,
|
|
84
86
|
initial_input=CORE_NODE_INPUT if group.lower().startswith('core') else b'',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""SSH connection strategies, tried in order until one succeeds."""
|
|
1
|
+
"""SSM and SSH connection strategies, tried in order until one succeeds."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import shlex
|
|
@@ -18,6 +18,8 @@ import paramiko
|
|
|
18
18
|
from loguru import logger
|
|
19
19
|
from typing_extensions import override
|
|
20
20
|
|
|
21
|
+
from aws_ssh_utils.session import ShellChannel, SSMSession
|
|
22
|
+
|
|
21
23
|
# CSV of issuer,client
|
|
22
24
|
OPKSSH_PROVIDER_TAG = 'opkssh_provider'
|
|
23
25
|
# EMR doesn't support comma's in tags.
|
|
@@ -54,7 +56,7 @@ class Environment:
|
|
|
54
56
|
|
|
55
57
|
@dataclass(frozen=True)
|
|
56
58
|
class Connection:
|
|
57
|
-
client: paramiko.SSHClient
|
|
59
|
+
client: paramiko.SSHClient | SSMSession
|
|
58
60
|
via: str
|
|
59
61
|
"""How the connection was made: AWS SSM, opkssh, a key name, ..."""
|
|
60
62
|
|
|
@@ -64,7 +66,7 @@ class Attempt:
|
|
|
64
66
|
via: str
|
|
65
67
|
command: str
|
|
66
68
|
"""The equivalent command line, to retry by hand."""
|
|
67
|
-
open_client: Callable[[], paramiko.SSHClient] = field(repr=False, compare=False)
|
|
69
|
+
open_client: Callable[[], paramiko.SSHClient | SSMSession] = field(repr=False, compare=False)
|
|
68
70
|
state: Literal['pending', 'connecting', 'connected', 'failed', 'skipped'] = 'pending'
|
|
69
71
|
detail: str = ''
|
|
70
72
|
"""The error when failed, the reason when skipped."""
|
|
@@ -85,7 +87,7 @@ def connect(
|
|
|
85
87
|
|
|
86
88
|
Tried in order:
|
|
87
89
|
1. ~/.ssh/config, when a Host pattern (other than `*`) matches the instance id, an alias or the IP.
|
|
88
|
-
2.
|
|
90
|
+
2. A keyless AWS SSM shell, authenticated with the AWS profile.
|
|
89
91
|
3. opkssh, when the instance/cluster has opkssh tags. May open a browser login.
|
|
90
92
|
4. The EC2 key pair (or `target.key_file`), when a matching file is found in ~/.ssh.
|
|
91
93
|
5. The ssh-agent and default ~/.ssh/id_* keys.
|
|
@@ -96,6 +98,24 @@ def connect(
|
|
|
96
98
|
open_ssh_ = partial(open_ssh, confirm_host_key=confirm_host_key)
|
|
97
99
|
attempts: list[Attempt] = []
|
|
98
100
|
|
|
101
|
+
def try_attempt(i: int) -> Connection | None:
|
|
102
|
+
attempt = attempts[i]
|
|
103
|
+
if attempt.state == 'skipped':
|
|
104
|
+
on_progress(list(attempts))
|
|
105
|
+
return None
|
|
106
|
+
attempts[i] = replace(attempt, state='connecting')
|
|
107
|
+
on_progress(list(attempts))
|
|
108
|
+
try:
|
|
109
|
+
client = attempt.open_client()
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.debug(f"Failed to connect via {attempt.via}: {e!r}")
|
|
112
|
+
attempts[i] = replace(attempt, state='failed', detail=str(e) or type(e).__name__)
|
|
113
|
+
on_progress(list(attempts))
|
|
114
|
+
return None
|
|
115
|
+
attempts[i] = replace(attempt, state='connected')
|
|
116
|
+
on_progress(list(attempts))
|
|
117
|
+
return Connection(client, attempt.via)
|
|
118
|
+
|
|
99
119
|
config = find_ssh_config(target)
|
|
100
120
|
if config is not None:
|
|
101
121
|
alias, options = config
|
|
@@ -107,21 +127,18 @@ def connect(
|
|
|
107
127
|
),
|
|
108
128
|
)
|
|
109
129
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"AWS SSM",
|
|
115
|
-
ssh_command(target.user, target.instance_id, known_keys, proxy=ssm_proxy),
|
|
116
|
-
lambda: open_ssh_(
|
|
117
|
-
target.instance_id,
|
|
118
|
-
target.user,
|
|
119
|
-
known_keys,
|
|
120
|
-
use_defaults=True,
|
|
121
|
-
sock=proxy_socket(ssm_proxy, on_output),
|
|
122
|
-
),
|
|
130
|
+
command = ssm_command(target.instance_id, env)
|
|
131
|
+
ssm = Attempt('AWS SSM', shlex.join(command), lambda: SSMSession.open(command))
|
|
132
|
+
attempts.append(
|
|
133
|
+
ssm if env.ssm_installed else replace(ssm, state='skipped', detail="AWS CLI or session-manager-plugin not installed"),
|
|
123
134
|
)
|
|
124
|
-
|
|
135
|
+
for i in range(len(attempts)):
|
|
136
|
+
if result := try_attempt(i):
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
# Key discovery is only needed for the remaining SSH fallbacks.
|
|
140
|
+
fallback_start = len(attempts)
|
|
141
|
+
ec2_key = target.key_file or find_ssh_key_file(target.key_name)
|
|
125
142
|
|
|
126
143
|
if (provider := target.opkssh_provider) is not None:
|
|
127
144
|
opkssh_key = str(opkssh_key_path(provider))
|
|
@@ -150,21 +167,9 @@ def connect(
|
|
|
150
167
|
),
|
|
151
168
|
)
|
|
152
169
|
|
|
153
|
-
for i
|
|
154
|
-
if
|
|
155
|
-
|
|
156
|
-
attempts[i] = replace(attempt, state='connecting')
|
|
157
|
-
on_progress(list(attempts))
|
|
158
|
-
try:
|
|
159
|
-
client = attempt.open_client()
|
|
160
|
-
except Exception as e:
|
|
161
|
-
logger.debug(f"Failed to connect via {attempt.via}: {e!r}")
|
|
162
|
-
attempts[i] = replace(attempt, state='failed', detail=str(e) or type(e).__name__)
|
|
163
|
-
on_progress(list(attempts))
|
|
164
|
-
continue
|
|
165
|
-
attempts[i] = replace(attempt, state='connected')
|
|
166
|
-
on_progress(list(attempts))
|
|
167
|
-
return Connection(client, attempt.via)
|
|
170
|
+
for i in range(fallback_start, len(attempts)):
|
|
171
|
+
if result := try_attempt(i):
|
|
172
|
+
return result
|
|
168
173
|
return None
|
|
169
174
|
|
|
170
175
|
|
|
@@ -226,7 +231,9 @@ def ssh_command(
|
|
|
226
231
|
return shlex.join([*cmd, f'{user}@{host}'])
|
|
227
232
|
|
|
228
233
|
|
|
229
|
-
def open_shell_channel(client: paramiko.SSHClient) ->
|
|
234
|
+
def open_shell_channel(client: paramiko.SSHClient | SSMSession) -> ShellChannel:
|
|
235
|
+
if isinstance(client, SSMSession):
|
|
236
|
+
return client
|
|
230
237
|
channel = client.get_transport().open_session() # pyright: ignore[reportOptionalMemberAccess]
|
|
231
238
|
channel.get_pty(term='xterm-256color')
|
|
232
239
|
channel.invoke_shell()
|
|
@@ -301,10 +308,6 @@ def ssm_command(instance_id: str, env: Environment) -> list[str]:
|
|
|
301
308
|
'start-session',
|
|
302
309
|
'--target',
|
|
303
310
|
instance_id,
|
|
304
|
-
'--document-name',
|
|
305
|
-
'AWS-StartSSHSession',
|
|
306
|
-
'--parameters',
|
|
307
|
-
'portNumber=22',
|
|
308
311
|
]
|
|
309
312
|
if env.profile:
|
|
310
313
|
cmd += ['--profile', env.profile]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Terminal channels for native, keyless Session Manager shells."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Protocol, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ShellChannel(Protocol):
|
|
12
|
+
def recv(self, nbytes: int, /) -> bytes: ...
|
|
13
|
+
def send(self, data: bytes, /) -> int: ...
|
|
14
|
+
def resize_pty(self, width: int, height: int) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ShellClient(Protocol):
|
|
18
|
+
def close(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Pty(Protocol):
|
|
22
|
+
def read(self, size: int) -> str: ...
|
|
23
|
+
def write(self, data: str) -> int: ...
|
|
24
|
+
def setwinsize(self, rows: int, cols: int) -> None: ...
|
|
25
|
+
def close(self, force: bool = False) -> None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def spawn_pty(command: list[str]) -> Pty:
|
|
29
|
+
"""SSM needs a real terminal for raw input, control keys, and window sizes."""
|
|
30
|
+
env = {**os.environ, 'TERM': 'xterm-256color', 'AWS_PAGER': '', 'AWS_CLI_AUTO_PROMPT': 'off'}
|
|
31
|
+
if os.name == 'nt':
|
|
32
|
+
from winpty import PtyProcess
|
|
33
|
+
|
|
34
|
+
return cast(Pty, PtyProcess.spawn(command, env=env, dimensions=(24, 80)))
|
|
35
|
+
|
|
36
|
+
from ptyprocess import PtyProcessUnicode
|
|
37
|
+
|
|
38
|
+
return cast(Pty, PtyProcessUnicode.spawn(command, env=env, dimensions=(24, 80), echo=False))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SSMSession:
|
|
42
|
+
"""Adapt an AWS CLI pseudo-terminal to the terminal widget's channel interface."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, process: Pty):
|
|
45
|
+
self.process = process
|
|
46
|
+
self.closed = False
|
|
47
|
+
self._output: queue.Queue[bytes | Exception | None] = queue.Queue()
|
|
48
|
+
self._pending = b''
|
|
49
|
+
self._eof = False
|
|
50
|
+
threading.Thread(target=self._read, daemon=True).start()
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def open(cls, command: list[str], timeout: float = 30) -> 'SSMSession':
|
|
54
|
+
session = cls(spawn_pty(command))
|
|
55
|
+
try:
|
|
56
|
+
session._wait_started(timeout)
|
|
57
|
+
except BaseException:
|
|
58
|
+
session.close()
|
|
59
|
+
raise
|
|
60
|
+
return session
|
|
61
|
+
|
|
62
|
+
def _read(self):
|
|
63
|
+
try:
|
|
64
|
+
while not self.closed:
|
|
65
|
+
data = self.process.read(65536)
|
|
66
|
+
if data:
|
|
67
|
+
self._output.put(data.encode('utf-8'))
|
|
68
|
+
except EOFError:
|
|
69
|
+
pass
|
|
70
|
+
except Exception as e:
|
|
71
|
+
if not self.closed:
|
|
72
|
+
self._output.put(e)
|
|
73
|
+
finally:
|
|
74
|
+
self._output.put(None)
|
|
75
|
+
|
|
76
|
+
def _wait_started(self, timeout: float):
|
|
77
|
+
"""Keep startup output for the terminal, but report CLI failures to the fallback list."""
|
|
78
|
+
deadline = time.monotonic() + timeout
|
|
79
|
+
output = bytearray()
|
|
80
|
+
try:
|
|
81
|
+
while True:
|
|
82
|
+
data = self._output.get(timeout=max(0, deadline - time.monotonic()))
|
|
83
|
+
if isinstance(data, Exception):
|
|
84
|
+
raise OSError(str(data)) from data
|
|
85
|
+
if data is None:
|
|
86
|
+
raise OSError(output.decode('utf-8', errors='replace').strip() or 'SSM exited before starting a session')
|
|
87
|
+
output.extend(data)
|
|
88
|
+
# ConPTY adds terminal escape sequences to console output on Windows.
|
|
89
|
+
text = re.sub(rb'\x1b\[[0-?]*[ -/]*[@-~]', b'', output)
|
|
90
|
+
if re.search(rb'Starting session with SessionId:\s*\S+', text):
|
|
91
|
+
self._pending = bytes(output)
|
|
92
|
+
return
|
|
93
|
+
except queue.Empty:
|
|
94
|
+
detail = output.decode('utf-8', errors='replace').strip()
|
|
95
|
+
raise TimeoutError(f'Timed out starting SSM session{": " + detail if detail else ""}') from None
|
|
96
|
+
|
|
97
|
+
def recv(self, nbytes: int) -> bytes:
|
|
98
|
+
if nbytes <= 0:
|
|
99
|
+
return b''
|
|
100
|
+
while not self._pending and not self._eof:
|
|
101
|
+
data = self._output.get()
|
|
102
|
+
if isinstance(data, Exception):
|
|
103
|
+
raise OSError(str(data)) from data
|
|
104
|
+
if data is None:
|
|
105
|
+
self._eof = True
|
|
106
|
+
else:
|
|
107
|
+
self._pending = data
|
|
108
|
+
data, self._pending = self._pending[:nbytes], self._pending[nbytes:]
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
def send(self, data: bytes) -> int:
|
|
112
|
+
try:
|
|
113
|
+
self.process.write(data.decode('utf-8'))
|
|
114
|
+
except EOFError as e:
|
|
115
|
+
raise OSError('SSM session is closed') from e
|
|
116
|
+
return len(data)
|
|
117
|
+
|
|
118
|
+
def resize_pty(self, width: int, height: int):
|
|
119
|
+
if not self.closed:
|
|
120
|
+
self.process.setwinsize(height, width)
|
|
121
|
+
|
|
122
|
+
def close(self):
|
|
123
|
+
if not self.closed:
|
|
124
|
+
self.closed = True
|
|
125
|
+
try:
|
|
126
|
+
self.process.close(force=True)
|
|
127
|
+
finally:
|
|
128
|
+
self._output.put(None)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Textual widgets rendering
|
|
1
|
+
"""Textual widgets rendering SSH and SSM shell channels through the pyte VT100 emulator."""
|
|
2
2
|
|
|
3
3
|
import datetime as dt
|
|
4
4
|
import time
|
|
@@ -7,7 +7,6 @@ from collections.abc import Sequence
|
|
|
7
7
|
from functools import lru_cache
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
import paramiko
|
|
11
10
|
import pyte
|
|
12
11
|
from pyte.screens import Char, Margins
|
|
13
12
|
from rich.segment import Segment
|
|
@@ -22,6 +21,8 @@ from textual.widget import Widget
|
|
|
22
21
|
from textual.widgets import Static
|
|
23
22
|
from typing_extensions import override
|
|
24
23
|
|
|
24
|
+
from aws_ssh_utils.session import ShellChannel, ShellClient
|
|
25
|
+
|
|
25
26
|
KEYS = {
|
|
26
27
|
'enter': '\r',
|
|
27
28
|
'tab': '\t',
|
|
@@ -108,7 +109,7 @@ def cell_style(
|
|
|
108
109
|
class ChannelScreen(pyte.Screen):
|
|
109
110
|
"""Answers terminal queries over the channel, and keeps the lines scrolled off the top as history."""
|
|
110
111
|
|
|
111
|
-
def __init__(self, channel:
|
|
112
|
+
def __init__(self, channel: ShellChannel, columns: int, lines: int, scrollback: int = 0):
|
|
112
113
|
super().__init__(columns, lines)
|
|
113
114
|
self.channel = channel
|
|
114
115
|
self.history: deque[tuple[Char, ...]] = deque(maxlen=scrollback)
|
|
@@ -145,7 +146,7 @@ class Terminal(Widget, can_focus=True):
|
|
|
145
146
|
class Closed(Message):
|
|
146
147
|
pass
|
|
147
148
|
|
|
148
|
-
def __init__(self, client:
|
|
149
|
+
def __init__(self, client: ShellClient, channel: ShellChannel, scrollback: int = 0, **kwargs: object):
|
|
149
150
|
super().__init__(**kwargs) # pyright: ignore[reportArgumentType]
|
|
150
151
|
self.client = client
|
|
151
152
|
self.channel = channel
|
|
@@ -287,7 +288,7 @@ class ShellApp(App[None]):
|
|
|
287
288
|
# Its priority ctrl+p binding would steal shell history navigation from the terminal.
|
|
288
289
|
ENABLE_COMMAND_PALETTE = False
|
|
289
290
|
|
|
290
|
-
def __init__(self, via: str, client:
|
|
291
|
+
def __init__(self, via: str, client: ShellClient, channel: ShellChannel, scrollback: int = 0):
|
|
291
292
|
super().__init__()
|
|
292
293
|
self.via = via
|
|
293
294
|
self.client = client
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.7"
|
|
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
|