aws-ssh-utils 0.2.7__tar.gz → 0.3.1__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.1}/PKG-INFO +26 -7
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/README.md +131 -114
- aws_ssh_utils-0.3.1/aws_ssh_utils/__init__.py +1 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/cli_utils.py +3 -4
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/app.py +20 -11
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/ec2.py +1 -1
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/emr.py +5 -3
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/connection.py +41 -38
- aws_ssh_utils-0.3.1/aws_ssh_utils/logging_utils.py +37 -0
- aws_ssh_utils-0.3.1/aws_ssh_utils/session.py +128 -0
- aws_ssh_utils-0.3.1/aws_ssh_utils/ssh.py +124 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/terminal.py +30 -10
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/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/ssh.py +0 -65
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/.gitignore +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/LICENSE +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/__init__.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/emr_all.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/healthcheck.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/ec2_utils.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/emr_utils.py +0 -0
- {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/py.typed +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.1
|
|
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
|
|
@@ -83,6 +89,19 @@ Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back
|
|
|
83
89
|
`F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits. Every other key goes to the focused shell.
|
|
84
90
|
Logs are viewable with `textual console`.
|
|
85
91
|
|
|
92
|
+
To keep diagnostics and crash tracebacks in a file, use the global `--log-file` option:
|
|
93
|
+
|
|
94
|
+
```shell
|
|
95
|
+
aws-ssh-utils --log-file ./aws-ssh.log --verbose --profile my-profile
|
|
96
|
+
# Also works before an explicit command:
|
|
97
|
+
aws-ssh-utils --log-file ./aws-ssh.log ec2 --profile my-profile
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Alternatively, set `AWS_SSH_LOG_FILE` to the file path. Logs append across runs and rotate at 10 MB,
|
|
101
|
+
keeping up to three rotated files. `--verbose` includes debug diagnostics; `--quiet` only suppresses
|
|
102
|
+
console logging. File logs include the version, source location, and error tracebacks, without local
|
|
103
|
+
variable dumps or a recording of shell input/output.
|
|
104
|
+
|
|
86
105
|
## EC2
|
|
87
106
|
|
|
88
107
|
Select an instance from an interactive list. You can filter the instances by name.
|
|
@@ -92,7 +111,7 @@ $ aws_auth ec2 --help
|
|
|
92
111
|
Usage: aws_ssh ec2 [OPTIONS]
|
|
93
112
|
|
|
94
113
|
Asks user which EC2 instance they want to connect to, then opens an
|
|
95
|
-
interactive SSH session to the instance
|
|
114
|
+
interactive SSM or SSH session to the instance
|
|
96
115
|
|
|
97
116
|
Options:
|
|
98
117
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -114,7 +133,7 @@ Select the EMR cluster, instance group, and instance to connect to.
|
|
|
114
133
|
$ aws_ssh emr --help
|
|
115
134
|
Usage: aws_ssh emr [OPTIONS]
|
|
116
135
|
|
|
117
|
-
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSH session to the instance
|
|
136
|
+
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSM or SSH session to the instance
|
|
118
137
|
|
|
119
138
|
Options:
|
|
120
139
|
-p, --profile TEXT Which AWS profile to use
|
|
@@ -1,114 +1,131 @@
|
|
|
1
|
-
# AWS SSH Utils
|
|
2
|
-
|
|
3
|
-
[](https://pypi.org/project/aws-ssh-utils/)
|
|
4
|
-
[](#)
|
|
5
|
-
[](https://pypi.org/project/aws-ssh-utils/)
|
|
6
|
-
|
|
7
|
-
```shell
|
|
8
|
-
uvx aws-ssh-utils
|
|
9
|
-
|
|
10
|
-
uvx aws-ssh-utils app # optional - `app` is default.
|
|
11
|
-
uvx aws-ssh-utils ec2
|
|
12
|
-
uvx aws-ssh-utils emr
|
|
13
|
-
uvx aws-ssh-utils emr-all
|
|
14
|
-
|
|
15
|
-
# Also exposed as `aws_ssh`
|
|
16
|
-
uvx --from aws-ssh-utils aws_ssh
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
This allows you to interactively SSH to an EC2 instance, EMR instance, or all EMR instances with TMUX.
|
|
20
|
-
|
|
21
|
-
It utilizes [questionary](https://pypi.org/project/questionary/) to ask you which instance you want to connect to.
|
|
22
|
-
|
|
23
|
-
## App
|
|
24
|
-
|
|
25
|
-
Terminal UI to browse EC2 instances and EMR clusters, with SSH shells in tabs.
|
|
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`,
|
|
28
|
-
the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
# AWS SSH Utils
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/aws-ssh-utils/)
|
|
4
|
+
[](#)
|
|
5
|
+
[](https://pypi.org/project/aws-ssh-utils/)
|
|
6
|
+
|
|
7
|
+
```shell
|
|
8
|
+
uvx aws-ssh-utils
|
|
9
|
+
|
|
10
|
+
uvx aws-ssh-utils app # optional - `app` is default.
|
|
11
|
+
uvx aws-ssh-utils ec2
|
|
12
|
+
uvx aws-ssh-utils emr
|
|
13
|
+
uvx aws-ssh-utils emr-all
|
|
14
|
+
|
|
15
|
+
# Also exposed as `aws_ssh`
|
|
16
|
+
uvx --from aws-ssh-utils aws_ssh
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This allows you to interactively SSH to an EC2 instance, EMR instance, or all EMR instances with TMUX.
|
|
20
|
+
|
|
21
|
+
It utilizes [questionary](https://pypi.org/project/questionary/) to ask you which instance you want to connect to.
|
|
22
|
+
|
|
23
|
+
## App
|
|
24
|
+
|
|
25
|
+
Terminal UI to browse EC2 instances and EMR clusters, with SSM or SSH shells in tabs.
|
|
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`, a keyless AWS SSM shell, opkssh (via instance/cluster tags),
|
|
28
|
+
the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
|
|
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.
|
|
34
|
+
|
|
35
|
+
```shell
|
|
36
|
+
$ aws_ssh app --help
|
|
37
|
+
Usage: aws_ssh app [OPTIONS]
|
|
38
|
+
|
|
39
|
+
Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs.
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
-p, --profile TEXT Which AWS profile to use
|
|
43
|
+
-r, --region TEXT Which AWS region to use
|
|
44
|
+
--scrollback INTEGER RANGE Lines of scrollback per shell. Defaults to your
|
|
45
|
+
tmux or Windows Terminal setting, else 1000.
|
|
46
|
+
[x>=0]
|
|
47
|
+
--help Show this message and exit.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back to the prompt.
|
|
51
|
+
`F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits. Every other key goes to the focused shell.
|
|
52
|
+
Logs are viewable with `textual console`.
|
|
53
|
+
|
|
54
|
+
To keep diagnostics and crash tracebacks in a file, use the global `--log-file` option:
|
|
55
|
+
|
|
56
|
+
```shell
|
|
57
|
+
aws-ssh-utils --log-file ./aws-ssh.log --verbose --profile my-profile
|
|
58
|
+
# Also works before an explicit command:
|
|
59
|
+
aws-ssh-utils --log-file ./aws-ssh.log ec2 --profile my-profile
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Alternatively, set `AWS_SSH_LOG_FILE` to the file path. Logs append across runs and rotate at 10 MB,
|
|
63
|
+
keeping up to three rotated files. `--verbose` includes debug diagnostics; `--quiet` only suppresses
|
|
64
|
+
console logging. File logs include the version, source location, and error tracebacks, without local
|
|
65
|
+
variable dumps or a recording of shell input/output.
|
|
66
|
+
|
|
67
|
+
## EC2
|
|
68
|
+
|
|
69
|
+
Select an instance from an interactive list. You can filter the instances by name.
|
|
70
|
+
|
|
71
|
+
```shell
|
|
72
|
+
$ aws_auth ec2 --help
|
|
73
|
+
Usage: aws_ssh ec2 [OPTIONS]
|
|
74
|
+
|
|
75
|
+
Asks user which EC2 instance they want to connect to, then opens an
|
|
76
|
+
interactive SSM or SSH session to the instance
|
|
77
|
+
|
|
78
|
+
Options:
|
|
79
|
+
-p, --profile TEXT Which AWS profile to use
|
|
80
|
+
-r, --region TEXT Which AWS region to use
|
|
81
|
+
-u, --user TEXT Which user to connect as
|
|
82
|
+
--private / --public Connect to the instance's private or public IP
|
|
83
|
+
-k, --key-file FILE Which key file to use to connect
|
|
84
|
+
--scrollback INTEGER RANGE
|
|
85
|
+
Lines of scrollback per shell. Defaults to your tmux or
|
|
86
|
+
Windows Terminal setting, else 1000. [x>=0]
|
|
87
|
+
--help Show this message and exit.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## EMR
|
|
91
|
+
|
|
92
|
+
Select the EMR cluster, instance group, and instance to connect to.
|
|
93
|
+
|
|
94
|
+
```shell
|
|
95
|
+
$ aws_ssh emr --help
|
|
96
|
+
Usage: aws_ssh emr [OPTIONS]
|
|
97
|
+
|
|
98
|
+
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSM or SSH session to the instance
|
|
99
|
+
|
|
100
|
+
Options:
|
|
101
|
+
-p, --profile TEXT Which AWS profile to use
|
|
102
|
+
-r, --region TEXT Which AWS region to use
|
|
103
|
+
-u, --user TEXT Which user to connect as
|
|
104
|
+
--private / --public Connect to the instance's private or public IP
|
|
105
|
+
-k, --key-file FILE Which key file to use to connect
|
|
106
|
+
--scrollback INTEGER RANGE
|
|
107
|
+
Lines of scrollback per shell. Defaults to your tmux or
|
|
108
|
+
Windows Terminal setting, else 1000. [x>=0]
|
|
109
|
+
--help Show this message and exit.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## EMR All
|
|
113
|
+
|
|
114
|
+
Select the EMR cluster to connect to. Then creates a new TMUX session with a window per instance. Each window will have an SSH connection to that instance open.
|
|
115
|
+
|
|
116
|
+
```shell
|
|
117
|
+
$ aws_ssh emr-all --help
|
|
118
|
+
Usage: aws_ssh emr-all [OPTIONS]
|
|
119
|
+
|
|
120
|
+
Asks user which Cluster and EC2 instance they want to connect to, Then
|
|
121
|
+
prints a tmux cli statement that will open a new session with a window per
|
|
122
|
+
ec2 instance with ssh shell already opened.
|
|
123
|
+
|
|
124
|
+
Options:
|
|
125
|
+
-p, --profile TEXT Which AWS profile to use
|
|
126
|
+
-r, --region TEXT Which AWS region to use
|
|
127
|
+
-u, --user TEXT Which user to connect as
|
|
128
|
+
--private / --public Connect to the instance's private or public IP
|
|
129
|
+
-k, --key-file FILE Which key file to use to connect
|
|
130
|
+
--help Show this message and exit.
|
|
131
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -26,6 +26,7 @@ from rich.text import Text
|
|
|
26
26
|
|
|
27
27
|
from aws_ssh_utils.commands.healthcheck import is_aws_ssm_installed, is_opkssh_installed
|
|
28
28
|
from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
|
|
29
|
+
from aws_ssh_utils.logging_utils import terminal_logging
|
|
29
30
|
from aws_ssh_utils.terminal import ShellApp
|
|
30
31
|
|
|
31
32
|
P = ParamSpec('P')
|
|
@@ -202,10 +203,8 @@ def open_shell(target: Target, env: Environment, title: str, scrollback: int | N
|
|
|
202
203
|
channel.send(initial_input)
|
|
203
204
|
|
|
204
205
|
set_terminal_title(title)
|
|
205
|
-
# Logs, e.g. AWS SSM's stderr, would draw over the full-screen shell.
|
|
206
|
-
logger.disable('aws_ssh_utils')
|
|
207
206
|
try:
|
|
208
|
-
|
|
207
|
+
with terminal_logging():
|
|
208
|
+
ShellApp(result.via, result.client, channel, detect_scrollback() if scrollback is None else scrollback).run()
|
|
209
209
|
finally:
|
|
210
|
-
logger.enable('aws_ssh_utils')
|
|
211
210
|
set_terminal_title()
|
|
@@ -14,10 +14,9 @@ from rich.markup import escape
|
|
|
14
14
|
from rich.spinner import Spinner
|
|
15
15
|
from rich.text import Text
|
|
16
16
|
from textual import work
|
|
17
|
-
from textual.app import
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
18
|
from textual.binding import Binding, BindingType
|
|
19
19
|
from textual.containers import Horizontal
|
|
20
|
-
from textual.logging import TextualHandler
|
|
21
20
|
from textual.widgets import Footer, Input, OptionList, RichLog, Static, TabbedContent, TabPane, Tree
|
|
22
21
|
from textual.widgets.option_list import Option
|
|
23
22
|
from textual.widgets.tree import TreeNode
|
|
@@ -26,6 +25,7 @@ from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, rende
|
|
|
26
25
|
from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
|
|
27
26
|
from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances
|
|
28
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 LoggedApp, terminal_logging
|
|
29
29
|
from aws_ssh_utils.terminal import ShellStatus, Terminal
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
@@ -48,21 +48,19 @@ 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()
|
|
55
55
|
except (BotoCoreError, ClientError) as e:
|
|
56
|
+
logger.bind(file_only=True).opt(exception=e).error('AWS authentication failed')
|
|
56
57
|
Console().print(f"[red]✗[/] {escape(str(e))}")
|
|
57
58
|
sys.exit(1)
|
|
58
59
|
|
|
59
|
-
# loguru holds on to the real stdout, which would draw over the TUI. View logs with `textual console`.
|
|
60
|
-
logger.remove()
|
|
61
|
-
logger.add(TextualHandler(), format="{message}")
|
|
62
|
-
|
|
63
60
|
env = detect_environment(profile, session.region_name)
|
|
64
61
|
scrollback = detect_scrollback() if scrollback is None else scrollback
|
|
65
|
-
|
|
62
|
+
with terminal_logging():
|
|
63
|
+
SSHApp(session.client('ec2'), session.client('emr'), env, scrollback).run()
|
|
66
64
|
|
|
67
65
|
|
|
68
66
|
def muted_label(name: str, instance_id: str) -> Text:
|
|
@@ -112,10 +110,10 @@ class ShellPane(TabPane):
|
|
|
112
110
|
def update_label(self):
|
|
113
111
|
icon = cast(Text, self.spinner.render(time.monotonic())) if self.status == 'connecting' else STATUS_ICONS[self.status]
|
|
114
112
|
self.query_ancestor(TabbedContent).get_tab(self).label = Text.assemble(icon, ' ', self.title_)
|
|
115
|
-
self.query_one('.attempts', Static).update(render_attempts(self.attempts))
|
|
116
113
|
|
|
117
114
|
def set_attempts(self, attempts: list[Attempt]):
|
|
118
115
|
self.attempts = attempts
|
|
116
|
+
self.query_one('.attempts', Static).update(render_attempts(self.attempts))
|
|
119
117
|
self.update_label()
|
|
120
118
|
|
|
121
119
|
def set_status(self, status: Status):
|
|
@@ -154,16 +152,20 @@ class ShellPane(TabPane):
|
|
|
154
152
|
self.app.call_from_thread(self.attach, result.via, Terminal(result.client, channel, self.scrollback))
|
|
155
153
|
|
|
156
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')
|
|
157
159
|
await self.query_one(RichLog).remove()
|
|
158
160
|
await self.query_one('.attempts').remove()
|
|
159
161
|
await self.mount(ShellStatus(via), terminal)
|
|
160
|
-
self.set_status('connected')
|
|
161
162
|
|
|
162
163
|
def on_terminal_closed(self):
|
|
164
|
+
logger.info('Shell tab {} closed', self.id)
|
|
163
165
|
self.query_ancestor(TabbedContent).remove_pane(self.id or '')
|
|
164
166
|
|
|
165
167
|
|
|
166
|
-
class SSHApp(
|
|
168
|
+
class SSHApp(LoggedApp):
|
|
167
169
|
"""The textual app"""
|
|
168
170
|
|
|
169
171
|
TITLE = "AWS SSH"
|
|
@@ -252,9 +254,16 @@ class SSHApp(App[None]):
|
|
|
252
254
|
if shells.active:
|
|
253
255
|
shells.remove_pane(shells.active)
|
|
254
256
|
|
|
257
|
+
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated):
|
|
258
|
+
if event.tabbed_content.id != 'shells' or event.pane.id != event.tabbed_content.active:
|
|
259
|
+
return
|
|
260
|
+
if terminals := event.pane.query(Terminal):
|
|
261
|
+
terminals.first().focus()
|
|
262
|
+
|
|
255
263
|
def open_shell(self, title: str, resolve: Callable[[], Target]):
|
|
256
264
|
shells = self.query_one('#shells', TabbedContent)
|
|
257
265
|
pane = ShellPane(title, resolve, self.env, self.scrollback, id=f'shell-{next(self.pane_ids)}')
|
|
266
|
+
logger.info('Opening shell tab {} ({})', pane.id, title)
|
|
258
267
|
shells.add_pane(pane)
|
|
259
268
|
shells.active = pane.id or ''
|
|
260
269
|
|
|
@@ -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,37 @@
|
|
|
1
|
+
"""Keep diagnostics available without drawing over the full-screen terminal."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from textual.app import App
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
_terminal_active = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_console(message: str) -> None:
|
|
15
|
+
# Worker threads don't inherit Textual's active-app context. Suppress the
|
|
16
|
+
# console process-wide while the UI runs; file logging remains enabled.
|
|
17
|
+
if not _terminal_active:
|
|
18
|
+
sys.stdout.write(message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def terminal_logging() -> Iterator[None]:
|
|
23
|
+
global _terminal_active
|
|
24
|
+
previous = _terminal_active
|
|
25
|
+
_terminal_active = True
|
|
26
|
+
try:
|
|
27
|
+
yield
|
|
28
|
+
finally:
|
|
29
|
+
_terminal_active = previous
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoggedApp(App[None]):
|
|
33
|
+
@override
|
|
34
|
+
def _handle_exception(self, error: Exception) -> None:
|
|
35
|
+
# Textual handles UI errors internally instead of raising from run().
|
|
36
|
+
logger.opt(exception=error).error('Unhandled {} error', type(self).__name__)
|
|
37
|
+
super()._handle_exception(error)
|
|
@@ -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)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from textual.logging import TextualHandler
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
import aws_ssh_utils
|
|
11
|
+
from aws_ssh_utils.commands.app import app
|
|
12
|
+
from aws_ssh_utils.commands.ec2 import ec2
|
|
13
|
+
from aws_ssh_utils.commands.emr import emr
|
|
14
|
+
from aws_ssh_utils.commands.emr_all import emr_all
|
|
15
|
+
from aws_ssh_utils.commands.healthcheck import healthcheck
|
|
16
|
+
from aws_ssh_utils.logging_utils import write_console
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DefaultCommandGroup(click.Group):
|
|
20
|
+
"""Runs `default_command` when no subcommand is given, e.g. `aws_ssh -p prod` -> `aws_ssh app -p prod`.
|
|
21
|
+
|
|
22
|
+
Skip group options and their values before looking for the subcommand.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *args: Any, default_command: str, **kwargs: Any):
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
self.default_command = default_command
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
31
|
+
group_options = {opt: param for param in self.get_params(ctx) for opt in (*param.opts, *param.secondary_opts)}
|
|
32
|
+
idx = 0
|
|
33
|
+
while idx < len(args):
|
|
34
|
+
name, separator, _ = args[idx].partition('=')
|
|
35
|
+
param = group_options.get(name)
|
|
36
|
+
if not isinstance(param, click.Option):
|
|
37
|
+
break
|
|
38
|
+
idx += 1
|
|
39
|
+
if not param.is_flag and not param.count and not separator:
|
|
40
|
+
if len(args) < idx + param.nargs:
|
|
41
|
+
return super().parse_args(ctx, args)
|
|
42
|
+
idx += param.nargs
|
|
43
|
+
if idx == len(args) or args[idx].startswith('-'):
|
|
44
|
+
args = [*args[:idx], self.default_command, *args[idx:]]
|
|
45
|
+
return super().parse_args(ctx, args)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@click.group(cls=DefaultCommandGroup, default_command='app')
|
|
49
|
+
@click.option('-ll/', '--long-log/--no-long-log', default=False, help='Enable long logging')
|
|
50
|
+
@click.option('--verbose/--no-verbose', default=False, help='Enable debug logging')
|
|
51
|
+
@click.option('--quiet/--no-quiet', default=False, help='Disable console logging')
|
|
52
|
+
@click.option(
|
|
53
|
+
'--log-file',
|
|
54
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
55
|
+
envvar='AWS_SSH_LOG_FILE',
|
|
56
|
+
help='Append diagnostics and tracebacks to this file. Also settable with AWS_SSH_LOG_FILE.',
|
|
57
|
+
)
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def cli(
|
|
60
|
+
ctx: click.Context,
|
|
61
|
+
long_log: bool = False,
|
|
62
|
+
verbose: bool = False,
|
|
63
|
+
quiet: bool = False,
|
|
64
|
+
log_file: Path | None = None,
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
):
|
|
67
|
+
"""Runs `app` when no command is given."""
|
|
68
|
+
log_format = (
|
|
69
|
+
(
|
|
70
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
71
|
+
"<level>{level: <8}</level> | "
|
|
72
|
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
|
|
73
|
+
)
|
|
74
|
+
if long_log
|
|
75
|
+
else "<level>{message}</level>"
|
|
76
|
+
)
|
|
77
|
+
level = 100 if quiet else "DEBUG" if verbose else "INFO"
|
|
78
|
+
|
|
79
|
+
logger.remove()
|
|
80
|
+
# Route to Textual's console when an app is active, and stdout otherwise.
|
|
81
|
+
# Keep the file sink active through both full-screen and questionary flows.
|
|
82
|
+
logger.add(
|
|
83
|
+
write_console,
|
|
84
|
+
level=level,
|
|
85
|
+
format=log_format,
|
|
86
|
+
colorize=True,
|
|
87
|
+
diagnose=False,
|
|
88
|
+
backtrace=False,
|
|
89
|
+
filter=lambda record: not record['extra'].get('file_only'),
|
|
90
|
+
)
|
|
91
|
+
logger.add(
|
|
92
|
+
TextualHandler(stderr=False, stdout=False),
|
|
93
|
+
level=level,
|
|
94
|
+
format='{message}',
|
|
95
|
+
diagnose=False,
|
|
96
|
+
backtrace=False,
|
|
97
|
+
filter=lambda record: not record['extra'].get('file_only'),
|
|
98
|
+
)
|
|
99
|
+
if log_file is not None:
|
|
100
|
+
try:
|
|
101
|
+
sink = logger.add(
|
|
102
|
+
log_file,
|
|
103
|
+
level='DEBUG' if verbose else 'INFO',
|
|
104
|
+
format='{time:YYYY-MM-DD HH:mm:ss.SSS Z} | {level: <8} | {name}:{function}:{line} | {message}',
|
|
105
|
+
encoding='utf-8',
|
|
106
|
+
colorize=False,
|
|
107
|
+
diagnose=False,
|
|
108
|
+
backtrace=False,
|
|
109
|
+
rotation='10 MB',
|
|
110
|
+
retention=3,
|
|
111
|
+
)
|
|
112
|
+
except OSError as error:
|
|
113
|
+
raise click.ClickException(f'Cannot open log file {log_file}: {error}') from error
|
|
114
|
+
ctx.call_on_close(lambda: logger.remove(sink))
|
|
115
|
+
logger.bind(file_only=True).info(
|
|
116
|
+
'aws-ssh-utils {}; Python {}; source={}',
|
|
117
|
+
aws_ssh_utils.__version__,
|
|
118
|
+
sys.version.split()[0],
|
|
119
|
+
aws_ssh_utils.__file__,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
for command in (app, ec2, emr, emr_all, healthcheck):
|
|
124
|
+
cli.add_command(command)
|
|
@@ -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,14 +7,14 @@ 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
|
|
11
|
+
from loguru import logger
|
|
12
12
|
from pyte.screens import Char, Margins
|
|
13
13
|
from rich.segment import Segment
|
|
14
14
|
from rich.style import Style
|
|
15
15
|
from rich.text import Text
|
|
16
16
|
from textual import events, work
|
|
17
|
-
from textual.app import
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
18
|
from textual.containers import Horizontal
|
|
19
19
|
from textual.message import Message
|
|
20
20
|
from textual.strip import Strip
|
|
@@ -22,6 +22,9 @@ from textual.widget import Widget
|
|
|
22
22
|
from textual.widgets import Static
|
|
23
23
|
from typing_extensions import override
|
|
24
24
|
|
|
25
|
+
from aws_ssh_utils.logging_utils import LoggedApp
|
|
26
|
+
from aws_ssh_utils.session import ShellChannel, ShellClient
|
|
27
|
+
|
|
25
28
|
KEYS = {
|
|
26
29
|
'enter': '\r',
|
|
27
30
|
'tab': '\t',
|
|
@@ -108,7 +111,7 @@ def cell_style(
|
|
|
108
111
|
class ChannelScreen(pyte.Screen):
|
|
109
112
|
"""Answers terminal queries over the channel, and keeps the lines scrolled off the top as history."""
|
|
110
113
|
|
|
111
|
-
def __init__(self, channel:
|
|
114
|
+
def __init__(self, channel: ShellChannel, columns: int, lines: int, scrollback: int = 0):
|
|
112
115
|
super().__init__(columns, lines)
|
|
113
116
|
self.channel = channel
|
|
114
117
|
self.history: deque[tuple[Char, ...]] = deque(maxlen=scrollback)
|
|
@@ -119,6 +122,14 @@ class ChannelScreen(pyte.Screen):
|
|
|
119
122
|
def write_process_input(self, data: str):
|
|
120
123
|
self.channel.send(data.encode())
|
|
121
124
|
|
|
125
|
+
@override
|
|
126
|
+
def select_graphic_rendition(self, *attrs: int, private: bool = False):
|
|
127
|
+
# Vim queries modifyOtherKeys with CSI ? 4 m. pyte 0.8.2 dispatches it
|
|
128
|
+
# here, but its SGR handler rejects `private`, killing the reader worker.
|
|
129
|
+
# Ignore unsupported keyboard queries without changing text attributes.
|
|
130
|
+
if not private:
|
|
131
|
+
super().select_graphic_rendition(*attrs)
|
|
132
|
+
|
|
122
133
|
@override
|
|
123
134
|
def index(self):
|
|
124
135
|
top, bottom = self.margins or Margins(0, self.lines - 1)
|
|
@@ -145,7 +156,7 @@ class Terminal(Widget, can_focus=True):
|
|
|
145
156
|
class Closed(Message):
|
|
146
157
|
pass
|
|
147
158
|
|
|
148
|
-
def __init__(self, client:
|
|
159
|
+
def __init__(self, client: ShellClient, channel: ShellChannel, scrollback: int = 0, **kwargs: object):
|
|
149
160
|
super().__init__(**kwargs) # pyright: ignore[reportArgumentType]
|
|
150
161
|
self.client = client
|
|
151
162
|
self.channel = channel
|
|
@@ -167,8 +178,15 @@ class Terminal(Widget, can_focus=True):
|
|
|
167
178
|
while data := self.channel.recv(65536):
|
|
168
179
|
self.app.call_from_thread(self.feed, data)
|
|
169
180
|
except OSError:
|
|
170
|
-
|
|
171
|
-
|
|
181
|
+
logger.exception('Terminal channel read failed')
|
|
182
|
+
except Exception:
|
|
183
|
+
logger.exception('Terminal reader failed')
|
|
184
|
+
raise
|
|
185
|
+
finally:
|
|
186
|
+
# Unexpected parser errors must also tear down the dead session.
|
|
187
|
+
# Textual records the worker exception, but exit_on_error=False
|
|
188
|
+
# would otherwise leave the last frame looking like a live terminal.
|
|
189
|
+
self.post_message(self.Closed())
|
|
172
190
|
|
|
173
191
|
def feed(self, data: bytes):
|
|
174
192
|
history_size = len(self.vt.history)
|
|
@@ -226,7 +244,9 @@ class Terminal(Widget, can_focus=True):
|
|
|
226
244
|
def render_line(self, y: int) -> Strip:
|
|
227
245
|
vt = self.vt
|
|
228
246
|
if y >= vt.lines:
|
|
229
|
-
|
|
247
|
+
# Layout may grow before on_resize updates pyte. Textual's color
|
|
248
|
+
# filters require a Style even for these temporary blank rows.
|
|
249
|
+
return Strip.blank(self.size.width, Style())
|
|
230
250
|
|
|
231
251
|
index = len(vt.history) - self.scrolled + y
|
|
232
252
|
if index < len(vt.history):
|
|
@@ -281,13 +301,13 @@ class ShellStatus(Horizontal):
|
|
|
281
301
|
self.query_one('.duration', Static).update(f"{hours:02}:{minutes:02}:{seconds:02}")
|
|
282
302
|
|
|
283
303
|
|
|
284
|
-
class ShellApp(
|
|
304
|
+
class ShellApp(LoggedApp):
|
|
285
305
|
"""A single full-screen shell, exits when the shell closes."""
|
|
286
306
|
|
|
287
307
|
# Its priority ctrl+p binding would steal shell history navigation from the terminal.
|
|
288
308
|
ENABLE_COMMAND_PALETTE = False
|
|
289
309
|
|
|
290
|
-
def __init__(self, via: str, client:
|
|
310
|
+
def __init__(self, via: str, client: ShellClient, channel: ShellChannel, scrollback: int = 0):
|
|
291
311
|
super().__init__()
|
|
292
312
|
self.via = via
|
|
293
313
|
self.client = client
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.7"
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
from typing import Any
|
|
3
|
-
|
|
4
|
-
import click
|
|
5
|
-
from loguru import logger
|
|
6
|
-
from typing_extensions import override
|
|
7
|
-
|
|
8
|
-
from aws_ssh_utils.commands.app import app
|
|
9
|
-
from aws_ssh_utils.commands.ec2 import ec2
|
|
10
|
-
from aws_ssh_utils.commands.emr import emr
|
|
11
|
-
from aws_ssh_utils.commands.emr_all import emr_all
|
|
12
|
-
from aws_ssh_utils.commands.healthcheck import healthcheck
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class DefaultCommandGroup(click.Group):
|
|
16
|
-
"""Runs `default_command` when no subcommand is given, e.g. `aws_ssh -p prod` -> `aws_ssh app -p prod`.
|
|
17
|
-
|
|
18
|
-
Assumes all group options are flags, so the first argument that isn't one is where the subcommand goes.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
def __init__(self, *args: Any, default_command: str, **kwargs: Any):
|
|
22
|
-
super().__init__(*args, **kwargs)
|
|
23
|
-
self.default_command = default_command
|
|
24
|
-
|
|
25
|
-
@override
|
|
26
|
-
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
27
|
-
group_options = {opt for param in self.get_params(ctx) for opt in (*param.opts, *param.secondary_opts)}
|
|
28
|
-
idx = next((i for i, arg in enumerate(args) if arg not in group_options), len(args))
|
|
29
|
-
if idx == len(args) or args[idx].startswith('-'):
|
|
30
|
-
args = [*args[:idx], self.default_command, *args[idx:]]
|
|
31
|
-
return super().parse_args(ctx, args)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@click.group(cls=DefaultCommandGroup, default_command='app')
|
|
35
|
-
@click.option('-ll/', '--long-log/--no-long-log', default=False, help='Enable long logging')
|
|
36
|
-
@click.option('--verbose/--no-verbose', default=False, help='Enable debug logging')
|
|
37
|
-
@click.option('--quiet/--no-quiet', default=False, help='Disable logging')
|
|
38
|
-
def cli(
|
|
39
|
-
long_log: bool = False,
|
|
40
|
-
verbose: bool = False,
|
|
41
|
-
quiet: bool = False,
|
|
42
|
-
**kwargs: Any,
|
|
43
|
-
):
|
|
44
|
-
"""Runs `app` when no command is given."""
|
|
45
|
-
log_format = (
|
|
46
|
-
(
|
|
47
|
-
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
48
|
-
"<level>{level: <8}</level> | "
|
|
49
|
-
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
|
|
50
|
-
)
|
|
51
|
-
if long_log
|
|
52
|
-
else "<level>{message}</level>"
|
|
53
|
-
)
|
|
54
|
-
level = "DEBUG" if verbose else 100 if quiet else "INFO"
|
|
55
|
-
|
|
56
|
-
def to_stdout(message: str):
|
|
57
|
-
# Look up sys.stdout per message, so rich.live can redirect it above its display.
|
|
58
|
-
sys.stdout.write(message)
|
|
59
|
-
|
|
60
|
-
logger.remove()
|
|
61
|
-
logger.add(to_stdout, level=level, format=log_format, colorize=True)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
for command in (app, ec2, emr, emr_all, healthcheck):
|
|
65
|
-
cli.add_command(command)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|