aws-ssh-utils 0.2.5__tar.gz → 0.2.7__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.5 → aws_ssh_utils-0.2.7}/PKG-INFO +8 -8
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/README.md +114 -114
- aws_ssh_utils-0.2.7/aws_ssh_utils/__init__.py +1 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/cli_utils.py +53 -7
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/app.py +12 -5
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/connection.py +97 -53
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/ssh.py +5 -1
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/pyproject.toml +1 -0
- aws_ssh_utils-0.2.5/aws_ssh_utils/__init__.py +0 -1
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/.gitignore +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/LICENSE +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/__init__.py +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/ec2.py +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/emr.py +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/emr_all.py +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/healthcheck.py +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/ec2_utils.py +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/emr_utils.py +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/py.typed +0 -0
- {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/terminal.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aws-ssh-utils
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
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>
|
|
@@ -41,15 +41,15 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
[](https://pypi.org/project/aws-ssh-utils/)
|
|
42
42
|
|
|
43
43
|
```shell
|
|
44
|
-
|
|
44
|
+
uvx aws-ssh-utils
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
uvx aws-ssh-utils app # optional - `app` is default.
|
|
47
|
+
uvx aws-ssh-utils ec2
|
|
48
|
+
uvx aws-ssh-utils emr
|
|
49
|
+
uvx aws-ssh-utils emr-all
|
|
47
50
|
|
|
48
|
-
aws_ssh
|
|
49
|
-
|
|
50
|
-
aws_ssh emr-all
|
|
51
|
-
|
|
52
|
-
aws_ssh app
|
|
51
|
+
# Also exposed as `aws_ssh`
|
|
52
|
+
uvx --from aws-ssh-utils aws_ssh
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
This allows you to interactively SSH to an EC2 instance, EMR instance, or all EMR instances with TMUX.
|
|
@@ -1,114 +1,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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
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`, SSH over AWS SSM (`session-manager-plugin`), opkssh (via instance/cluster tags),
|
|
28
|
-
the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
|
|
29
|
-
Unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
|
|
30
|
-
|
|
31
|
-
```shell
|
|
32
|
-
$ aws_ssh app --help
|
|
33
|
-
Usage: aws_ssh app [OPTIONS]
|
|
34
|
-
|
|
35
|
-
Browse EC2 instances and EMR clusters, and open SSH shells in tabs.
|
|
36
|
-
|
|
37
|
-
Options:
|
|
38
|
-
-p, --profile TEXT Which AWS profile to use
|
|
39
|
-
-r, --region TEXT Which AWS region to use
|
|
40
|
-
--scrollback INTEGER RANGE Lines of scrollback per shell. Defaults to your
|
|
41
|
-
tmux or Windows Terminal setting, else 1000.
|
|
42
|
-
[x>=0]
|
|
43
|
-
--help Show this message and exit.
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back to the prompt.
|
|
47
|
-
`F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits. Every other key goes to the focused shell.
|
|
48
|
-
Logs are viewable with `textual console`.
|
|
49
|
-
|
|
50
|
-
## EC2
|
|
51
|
-
|
|
52
|
-
Select an instance from an interactive list. You can filter the instances by name.
|
|
53
|
-
|
|
54
|
-
```shell
|
|
55
|
-
$ aws_auth ec2 --help
|
|
56
|
-
Usage: aws_ssh ec2 [OPTIONS]
|
|
57
|
-
|
|
58
|
-
Asks user which EC2 instance they want to connect to, then opens an
|
|
59
|
-
interactive SSH session to the instance
|
|
60
|
-
|
|
61
|
-
Options:
|
|
62
|
-
-p, --profile TEXT Which AWS profile to use
|
|
63
|
-
-r, --region TEXT Which AWS region to use
|
|
64
|
-
-u, --user TEXT Which user to connect as
|
|
65
|
-
--private / --public Connect to the instance's private or public IP
|
|
66
|
-
-k, --key-file FILE Which key file to use to connect
|
|
67
|
-
--scrollback INTEGER RANGE
|
|
68
|
-
Lines of scrollback per shell. Defaults to your tmux or
|
|
69
|
-
Windows Terminal setting, else 1000. [x>=0]
|
|
70
|
-
--help Show this message and exit.
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
## EMR
|
|
74
|
-
|
|
75
|
-
Select the EMR cluster, instance group, and instance to connect to.
|
|
76
|
-
|
|
77
|
-
```shell
|
|
78
|
-
$ aws_ssh emr --help
|
|
79
|
-
Usage: aws_ssh emr [OPTIONS]
|
|
80
|
-
|
|
81
|
-
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSH session to the instance
|
|
82
|
-
|
|
83
|
-
Options:
|
|
84
|
-
-p, --profile TEXT Which AWS profile to use
|
|
85
|
-
-r, --region TEXT Which AWS region to use
|
|
86
|
-
-u, --user TEXT Which user to connect as
|
|
87
|
-
--private / --public Connect to the instance's private or public IP
|
|
88
|
-
-k, --key-file FILE Which key file to use to connect
|
|
89
|
-
--scrollback INTEGER RANGE
|
|
90
|
-
Lines of scrollback per shell. Defaults to your tmux or
|
|
91
|
-
Windows Terminal setting, else 1000. [x>=0]
|
|
92
|
-
--help Show this message and exit.
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## EMR All
|
|
96
|
-
|
|
97
|
-
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.
|
|
98
|
-
|
|
99
|
-
```shell
|
|
100
|
-
$ aws_ssh emr-all --help
|
|
101
|
-
Usage: aws_ssh emr-all [OPTIONS]
|
|
102
|
-
|
|
103
|
-
Asks user which Cluster and EC2 instance they want to connect to, Then
|
|
104
|
-
prints a tmux cli statement that will open a new session with a window per
|
|
105
|
-
ec2 instance with ssh shell already opened.
|
|
106
|
-
|
|
107
|
-
Options:
|
|
108
|
-
-p, --profile TEXT Which AWS profile to use
|
|
109
|
-
-r, --region TEXT Which AWS region to use
|
|
110
|
-
-u, --user TEXT Which user to connect as
|
|
111
|
-
--private / --public Connect to the instance's private or public IP
|
|
112
|
-
-k, --key-file FILE Which key file to use to connect
|
|
113
|
-
--help Show this message and exit.
|
|
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 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`, SSH over AWS SSM (`session-manager-plugin`), opkssh (via instance/cluster tags),
|
|
28
|
+
the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
|
|
29
|
+
Unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
|
|
30
|
+
|
|
31
|
+
```shell
|
|
32
|
+
$ aws_ssh app --help
|
|
33
|
+
Usage: aws_ssh app [OPTIONS]
|
|
34
|
+
|
|
35
|
+
Browse EC2 instances and EMR clusters, and open SSH shells in tabs.
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
-p, --profile TEXT Which AWS profile to use
|
|
39
|
+
-r, --region TEXT Which AWS region to use
|
|
40
|
+
--scrollback INTEGER RANGE Lines of scrollback per shell. Defaults to your
|
|
41
|
+
tmux or Windows Terminal setting, else 1000.
|
|
42
|
+
[x>=0]
|
|
43
|
+
--help Show this message and exit.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back to the prompt.
|
|
47
|
+
`F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits. Every other key goes to the focused shell.
|
|
48
|
+
Logs are viewable with `textual console`.
|
|
49
|
+
|
|
50
|
+
## EC2
|
|
51
|
+
|
|
52
|
+
Select an instance from an interactive list. You can filter the instances by name.
|
|
53
|
+
|
|
54
|
+
```shell
|
|
55
|
+
$ aws_auth ec2 --help
|
|
56
|
+
Usage: aws_ssh ec2 [OPTIONS]
|
|
57
|
+
|
|
58
|
+
Asks user which EC2 instance they want to connect to, then opens an
|
|
59
|
+
interactive SSH session to the instance
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
-p, --profile TEXT Which AWS profile to use
|
|
63
|
+
-r, --region TEXT Which AWS region to use
|
|
64
|
+
-u, --user TEXT Which user to connect as
|
|
65
|
+
--private / --public Connect to the instance's private or public IP
|
|
66
|
+
-k, --key-file FILE Which key file to use to connect
|
|
67
|
+
--scrollback INTEGER RANGE
|
|
68
|
+
Lines of scrollback per shell. Defaults to your tmux or
|
|
69
|
+
Windows Terminal setting, else 1000. [x>=0]
|
|
70
|
+
--help Show this message and exit.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## EMR
|
|
74
|
+
|
|
75
|
+
Select the EMR cluster, instance group, and instance to connect to.
|
|
76
|
+
|
|
77
|
+
```shell
|
|
78
|
+
$ aws_ssh emr --help
|
|
79
|
+
Usage: aws_ssh emr [OPTIONS]
|
|
80
|
+
|
|
81
|
+
Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSH session to the instance
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
-p, --profile TEXT Which AWS profile to use
|
|
85
|
+
-r, --region TEXT Which AWS region to use
|
|
86
|
+
-u, --user TEXT Which user to connect as
|
|
87
|
+
--private / --public Connect to the instance's private or public IP
|
|
88
|
+
-k, --key-file FILE Which key file to use to connect
|
|
89
|
+
--scrollback INTEGER RANGE
|
|
90
|
+
Lines of scrollback per shell. Defaults to your tmux or
|
|
91
|
+
Windows Terminal setting, else 1000. [x>=0]
|
|
92
|
+
--help Show this message and exit.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## EMR All
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
```shell
|
|
100
|
+
$ aws_ssh emr-all --help
|
|
101
|
+
Usage: aws_ssh emr-all [OPTIONS]
|
|
102
|
+
|
|
103
|
+
Asks user which Cluster and EC2 instance they want to connect to, Then
|
|
104
|
+
prints a tmux cli statement that will open a new session with a window per
|
|
105
|
+
ec2 instance with ssh shell already opened.
|
|
106
|
+
|
|
107
|
+
Options:
|
|
108
|
+
-p, --profile TEXT Which AWS profile to use
|
|
109
|
+
-r, --region TEXT Which AWS region to use
|
|
110
|
+
-u, --user TEXT Which user to connect as
|
|
111
|
+
--private / --public Connect to the instance's private or public IP
|
|
112
|
+
-k, --key-file FILE Which key file to use to connect
|
|
113
|
+
--help Show this message and exit.
|
|
114
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.7"
|
|
@@ -6,10 +6,11 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
|
-
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from shutil import which
|
|
12
|
-
from typing import ParamSpec, TypeVar
|
|
13
|
+
from typing import ParamSpec, TypeVar, cast
|
|
13
14
|
|
|
14
15
|
import click
|
|
15
16
|
import paramiko
|
|
@@ -17,11 +18,14 @@ import questionary
|
|
|
17
18
|
from botocore.exceptions import ClientError
|
|
18
19
|
from loguru import logger
|
|
19
20
|
from rich.console import Console
|
|
21
|
+
from rich.live import Live
|
|
20
22
|
from rich.markup import escape
|
|
23
|
+
from rich.spinner import Spinner
|
|
21
24
|
from rich.status import Status
|
|
25
|
+
from rich.text import Text
|
|
22
26
|
|
|
23
27
|
from aws_ssh_utils.commands.healthcheck import is_aws_ssm_installed, is_opkssh_installed
|
|
24
|
-
from aws_ssh_utils.connection import Environment, Target, connect, open_shell_channel
|
|
28
|
+
from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
|
|
25
29
|
from aws_ssh_utils.terminal import ShellApp
|
|
26
30
|
|
|
27
31
|
P = ParamSpec('P')
|
|
@@ -37,6 +41,7 @@ WINDOWS_TERMINAL_SETTINGS = (
|
|
|
37
41
|
"""Relative to %LOCALAPPDATA%: Store, Store preview, and unpackaged installs."""
|
|
38
42
|
|
|
39
43
|
console = Console(stderr=True)
|
|
44
|
+
connecting_spinner = Spinner('dots')
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
class ShellError(Exception):
|
|
@@ -50,6 +55,29 @@ def spinner(message: str = '') -> Status:
|
|
|
50
55
|
return console.status(message, spinner='dots')
|
|
51
56
|
|
|
52
57
|
|
|
58
|
+
def render_attempts(attempts: Sequence[Attempt]) -> Text:
|
|
59
|
+
"""The connection checklist: done attempts, the current one with a spinner, and the remaining ones muted."""
|
|
60
|
+
|
|
61
|
+
def line(attempt: Attempt) -> Text:
|
|
62
|
+
match attempt.state:
|
|
63
|
+
case 'connecting':
|
|
64
|
+
frame = cast(Text, connecting_spinner.render(time.monotonic()))
|
|
65
|
+
return Text.assemble(frame, f" Connecting via {attempt.via} ...")
|
|
66
|
+
case 'connected':
|
|
67
|
+
return Text(f"✓ Connected via {attempt.via}", 'green')
|
|
68
|
+
case 'failed':
|
|
69
|
+
return Text.assemble(
|
|
70
|
+
f"❌ Failed to connect via {attempt.via} - {attempt.detail}\n",
|
|
71
|
+
(f" {attempt.command}", 'dim'),
|
|
72
|
+
)
|
|
73
|
+
case 'skipped':
|
|
74
|
+
return Text(f" {attempt.detail}, skipping.", 'dim')
|
|
75
|
+
case 'pending':
|
|
76
|
+
return Text(f" {attempt.via}", 'dim')
|
|
77
|
+
|
|
78
|
+
return Text('\n').join(line(a) for a in attempts)
|
|
79
|
+
|
|
80
|
+
|
|
53
81
|
def handle_errors(func: Callable[P, R]) -> Callable[P, R]:
|
|
54
82
|
"""Log ShellErrors and AWS errors, and exit, instead of printing a traceback."""
|
|
55
83
|
|
|
@@ -145,10 +173,28 @@ def open_shell(target: Target, env: Environment, title: str, scrollback: int | N
|
|
|
145
173
|
|
|
146
174
|
`scrollback` defaults to the terminal's own setting, see `detect_scrollback`.
|
|
147
175
|
"""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
176
|
+
attempts: list[Attempt] = []
|
|
177
|
+
with Live(console=console, get_renderable=lambda: render_attempts(attempts)) as live:
|
|
178
|
+
|
|
179
|
+
def on_progress(snapshot: list[Attempt]):
|
|
180
|
+
attempts[:] = snapshot
|
|
181
|
+
|
|
182
|
+
def confirm(hostname: str, key: paramiko.PKey) -> bool:
|
|
183
|
+
live.stop()
|
|
184
|
+
try:
|
|
185
|
+
return confirm_host_key(hostname, key)
|
|
186
|
+
finally:
|
|
187
|
+
live.start()
|
|
188
|
+
|
|
189
|
+
result = connect(
|
|
190
|
+
target,
|
|
191
|
+
env,
|
|
192
|
+
on_output=lambda line: live.console.print(escape(line)),
|
|
193
|
+
on_waiting=lambda: None,
|
|
194
|
+
on_progress=on_progress,
|
|
195
|
+
confirm_host_key=confirm,
|
|
196
|
+
)
|
|
197
|
+
if result is None:
|
|
152
198
|
raise ShellError(f'Failed to connect to {target.user}@{target.ip}')
|
|
153
199
|
|
|
154
200
|
channel = open_shell_channel(result.client)
|
|
@@ -22,8 +22,8 @@ from textual.widgets import Footer, Input, OptionList, RichLog, Static, TabbedCo
|
|
|
22
22
|
from textual.widgets.option_list import Option
|
|
23
23
|
from textual.widgets.tree import TreeNode
|
|
24
24
|
|
|
25
|
-
from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, scrollback_option
|
|
26
|
-
from aws_ssh_utils.connection import Environment, Target, connect, open_shell_channel
|
|
25
|
+
from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, render_attempts, scrollback_option
|
|
26
|
+
from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
|
|
27
27
|
from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances
|
|
28
28
|
from aws_ssh_utils.emr_utils import emr_target, get_emr_clusters, get_emr_instances, group_role, group_sort_key
|
|
29
29
|
from aws_ssh_utils.terminal import ShellStatus, Terminal
|
|
@@ -99,8 +99,10 @@ class ShellPane(TabPane):
|
|
|
99
99
|
self.scrollback = scrollback
|
|
100
100
|
self.status: Status = 'connecting'
|
|
101
101
|
self.spinner = Spinner('dots')
|
|
102
|
+
self.attempts: list[Attempt] = []
|
|
102
103
|
|
|
103
104
|
def compose(self) -> ComposeResult:
|
|
105
|
+
yield Static(classes='attempts')
|
|
104
106
|
yield RichLog(wrap=True, markup=True)
|
|
105
107
|
|
|
106
108
|
def on_mount(self):
|
|
@@ -110,6 +112,11 @@ class ShellPane(TabPane):
|
|
|
110
112
|
def update_label(self):
|
|
111
113
|
icon = cast(Text, self.spinner.render(time.monotonic())) if self.status == 'connecting' else STATUS_ICONS[self.status]
|
|
112
114
|
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
|
+
|
|
117
|
+
def set_attempts(self, attempts: list[Attempt]):
|
|
118
|
+
self.attempts = attempts
|
|
119
|
+
self.update_label()
|
|
113
120
|
|
|
114
121
|
def set_status(self, status: Status):
|
|
115
122
|
self.status = status
|
|
@@ -131,10 +138,9 @@ class ShellPane(TabPane):
|
|
|
131
138
|
self.env,
|
|
132
139
|
on_output=write,
|
|
133
140
|
on_waiting=lambda: self.app.call_from_thread(self.set_status, 'waiting'),
|
|
141
|
+
on_progress=lambda attempts: self.app.call_from_thread(self.set_attempts, attempts),
|
|
134
142
|
)
|
|
135
|
-
if
|
|
136
|
-
for line in result:
|
|
137
|
-
write(line)
|
|
143
|
+
if result is None:
|
|
138
144
|
self.app.call_from_thread(self.set_status, 'failed')
|
|
139
145
|
return
|
|
140
146
|
|
|
@@ -149,6 +155,7 @@ class ShellPane(TabPane):
|
|
|
149
155
|
|
|
150
156
|
async def attach(self, via: str, terminal: Terminal):
|
|
151
157
|
await self.query_one(RichLog).remove()
|
|
158
|
+
await self.query_one('.attempts').remove()
|
|
152
159
|
await self.mount(ShellStatus(via), terminal)
|
|
153
160
|
self.set_status('connected')
|
|
154
161
|
|
|
@@ -7,12 +7,12 @@ import subprocess
|
|
|
7
7
|
import threading
|
|
8
8
|
import time
|
|
9
9
|
from collections.abc import Callable, Sequence
|
|
10
|
-
from dataclasses import dataclass
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
11
|
from fnmatch import fnmatch
|
|
12
12
|
from functools import partial
|
|
13
13
|
from hashlib import sha256
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import IO, cast
|
|
15
|
+
from typing import IO, Literal, cast
|
|
16
16
|
|
|
17
17
|
import paramiko
|
|
18
18
|
from loguru import logger
|
|
@@ -59,14 +59,29 @@ class Connection:
|
|
|
59
59
|
"""How the connection was made: AWS SSM, opkssh, a key name, ..."""
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Attempt:
|
|
64
|
+
via: str
|
|
65
|
+
command: str
|
|
66
|
+
"""The equivalent command line, to retry by hand."""
|
|
67
|
+
open_client: Callable[[], paramiko.SSHClient] = field(repr=False, compare=False)
|
|
68
|
+
state: Literal['pending', 'connecting', 'connected', 'failed', 'skipped'] = 'pending'
|
|
69
|
+
detail: str = ''
|
|
70
|
+
"""The error when failed, the reason when skipped."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
OnProgress = Callable[[list[Attempt]], None]
|
|
74
|
+
|
|
75
|
+
|
|
62
76
|
def connect(
|
|
63
77
|
target: Target,
|
|
64
78
|
env: Environment,
|
|
65
79
|
on_output: OnOutput,
|
|
66
80
|
on_waiting: Callable[[], None],
|
|
81
|
+
on_progress: OnProgress,
|
|
67
82
|
confirm_host_key: ConfirmHostKey | None = None,
|
|
68
|
-
) -> Connection |
|
|
69
|
-
"""Return the first successful connection, or
|
|
83
|
+
) -> Connection | None:
|
|
84
|
+
"""Return the first successful connection, or None when all fail.
|
|
70
85
|
|
|
71
86
|
Tried in order:
|
|
72
87
|
1. ~/.ssh/config, when a Host pattern (other than `*`) matches the instance id, an alias or the IP.
|
|
@@ -75,76 +90,82 @@ def connect(
|
|
|
75
90
|
4. The EC2 key pair (or `target.key_file`), when a matching file is found in ~/.ssh.
|
|
76
91
|
5. The ssh-agent and default ~/.ssh/id_* keys.
|
|
77
92
|
|
|
93
|
+
`on_progress` gets a snapshot of all attempts whenever one changes state.
|
|
78
94
|
Unknown host keys are trusted, unless `confirm_host_key` declines them.
|
|
79
95
|
"""
|
|
80
|
-
failures: list[str] = []
|
|
81
96
|
open_ssh_ = partial(open_ssh, confirm_host_key=confirm_host_key)
|
|
82
|
-
|
|
83
|
-
def attempt(via: str, failure: str, open_client: Callable[[], paramiko.SSHClient]) -> Connection | None:
|
|
84
|
-
logger.info(f"Connecting via {via}")
|
|
85
|
-
try:
|
|
86
|
-
return Connection(open_client(), via)
|
|
87
|
-
except Exception as e:
|
|
88
|
-
logger.debug(f"{failure}: {e!r}")
|
|
89
|
-
failures.append(f"{failure}\n {e or type(e).__name__}")
|
|
90
|
-
return None
|
|
97
|
+
attempts: list[Attempt] = []
|
|
91
98
|
|
|
92
99
|
config = find_ssh_config(target)
|
|
93
100
|
if config is not None:
|
|
94
101
|
alias, options = config
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
attempts.append(
|
|
103
|
+
Attempt(
|
|
104
|
+
f"ssh config ({alias})",
|
|
105
|
+
shlex.join(['ssh', alias]),
|
|
106
|
+
lambda: open_with_config(alias, options, target, confirm_host_key),
|
|
107
|
+
),
|
|
108
|
+
)
|
|
101
109
|
|
|
102
110
|
ec2_key = target.key_file or find_ssh_key_file(target.key_name)
|
|
103
111
|
known_keys = [k for k in (valid_opkssh_key_file(target.opkssh_provider), ec2_key) if k]
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
failures.append(":x: AWS session-manager-plugin not installed")
|
|
107
|
-
elif connection := attempt(
|
|
112
|
+
ssm_proxy = ssm_command(target.instance_id, env)
|
|
113
|
+
ssm = Attempt(
|
|
108
114
|
"AWS SSM",
|
|
109
|
-
|
|
115
|
+
ssh_command(target.user, target.instance_id, known_keys, proxy=ssm_proxy),
|
|
110
116
|
lambda: open_ssh_(
|
|
111
117
|
target.instance_id,
|
|
112
118
|
target.user,
|
|
113
119
|
known_keys,
|
|
114
120
|
use_defaults=True,
|
|
115
|
-
sock=proxy_socket(
|
|
121
|
+
sock=proxy_socket(ssm_proxy, on_output),
|
|
116
122
|
),
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if target.opkssh_provider is not None:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
failures.append(":x: Failed to authenticate with opkssh\n opkssh not installed")
|
|
124
|
-
elif connection := attempt(
|
|
123
|
+
)
|
|
124
|
+
attempts.append(ssm if env.ssm_installed else replace(ssm, state='skipped', detail="session-manager-plugin not installed"))
|
|
125
|
+
|
|
126
|
+
if (provider := target.opkssh_provider) is not None:
|
|
127
|
+
opkssh_key = str(opkssh_key_path(provider))
|
|
128
|
+
opkssh = Attempt(
|
|
125
129
|
"opkssh",
|
|
126
|
-
"
|
|
130
|
+
f"{shlex.join(opkssh_login_command(provider, opkssh_key))} && "
|
|
131
|
+
+ ssh_command(target.user, target.ip, [opkssh_key], identities_only=True),
|
|
127
132
|
lambda: open_ssh_(target.ip, target.user, [opkssh_login(provider, on_output, on_waiting)], use_defaults=False),
|
|
128
|
-
):
|
|
129
|
-
return connection
|
|
130
|
-
|
|
131
|
-
if ec2_key is not None and (
|
|
132
|
-
connection := attempt(
|
|
133
|
-
Path(ec2_key).stem,
|
|
134
|
-
f":x: Key found, but failed - ssh -o IdentitiesOnly=yes -i {ec2_key} {target.user}@{target.ip}",
|
|
135
|
-
lambda: open_ssh_(target.ip, target.user, [ec2_key], use_defaults=False),
|
|
136
133
|
)
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
attempts.append(opkssh if env.opkssh_installed else replace(opkssh, state='skipped', detail="opkssh not installed"))
|
|
135
|
+
|
|
136
|
+
if ec2_key is not None:
|
|
137
|
+
attempts.append(
|
|
138
|
+
Attempt(
|
|
139
|
+
Path(ec2_key).stem,
|
|
140
|
+
ssh_command(target.user, target.ip, [ec2_key], identities_only=True),
|
|
141
|
+
lambda: open_ssh_(target.ip, target.user, [ec2_key], use_defaults=False),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
139
144
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
attempts.append(
|
|
146
|
+
Attempt(
|
|
147
|
+
"default key",
|
|
148
|
+
ssh_command(target.user, target.ip),
|
|
149
|
+
lambda: open_ssh_(target.ip, target.user, [], use_defaults=True),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
146
152
|
|
|
147
|
-
|
|
153
|
+
for i, attempt in enumerate(attempts):
|
|
154
|
+
if attempt.state == 'skipped':
|
|
155
|
+
continue
|
|
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)
|
|
168
|
+
return None
|
|
148
169
|
|
|
149
170
|
|
|
150
171
|
def open_ssh(
|
|
@@ -186,6 +207,25 @@ def open_ssh(
|
|
|
186
207
|
return client
|
|
187
208
|
|
|
188
209
|
|
|
210
|
+
def ssh_command(
|
|
211
|
+
user: str,
|
|
212
|
+
host: str,
|
|
213
|
+
key_files: Sequence[str] = (),
|
|
214
|
+
*,
|
|
215
|
+
identities_only: bool = False,
|
|
216
|
+
proxy: list[str] | None = None,
|
|
217
|
+
) -> str:
|
|
218
|
+
"""The OpenSSH command line equivalent of an `open_ssh` call."""
|
|
219
|
+
cmd = ['ssh']
|
|
220
|
+
if proxy:
|
|
221
|
+
cmd += ['-o', f'ProxyCommand={shlex.join(proxy)}']
|
|
222
|
+
if identities_only:
|
|
223
|
+
cmd += ['-o', 'IdentitiesOnly=yes']
|
|
224
|
+
for key_file in key_files:
|
|
225
|
+
cmd += ['-i', key_file]
|
|
226
|
+
return shlex.join([*cmd, f'{user}@{host}'])
|
|
227
|
+
|
|
228
|
+
|
|
189
229
|
def open_shell_channel(client: paramiko.SSHClient) -> paramiko.Channel:
|
|
190
230
|
channel = client.get_transport().open_session() # pyright: ignore[reportOptionalMemberAccess]
|
|
191
231
|
channel.get_pty(term='xterm-256color')
|
|
@@ -357,6 +397,10 @@ def valid_opkssh_key_file(provider: str | None) -> str | None:
|
|
|
357
397
|
return str(key) if key.is_file() and key.stat().st_mtime > time.time() - OPKSSH_KEY_MAX_AGE else None
|
|
358
398
|
|
|
359
399
|
|
|
400
|
+
def opkssh_login_command(provider: str, key_file: str) -> list[str]:
|
|
401
|
+
return ['opkssh', 'login', '--provider', provider, '-i', key_file]
|
|
402
|
+
|
|
403
|
+
|
|
360
404
|
def opkssh_login(provider: str, on_output: OnOutput, on_waiting: Callable[[], None]) -> str:
|
|
361
405
|
"""Return a valid opkssh key file, logging in when missing or expired."""
|
|
362
406
|
if key_file := valid_opkssh_key_file(provider):
|
|
@@ -368,7 +412,7 @@ def opkssh_login(provider: str, on_output: OnOutput, on_waiting: Callable[[], No
|
|
|
368
412
|
path.unlink(missing_ok=True)
|
|
369
413
|
|
|
370
414
|
proc = subprocess.Popen( # noqa: S603
|
|
371
|
-
|
|
415
|
+
opkssh_login_command(provider, str(key)),
|
|
372
416
|
stdout=subprocess.PIPE,
|
|
373
417
|
stderr=subprocess.STDOUT,
|
|
374
418
|
text=True,
|
|
@@ -53,8 +53,12 @@ def cli(
|
|
|
53
53
|
)
|
|
54
54
|
level = "DEBUG" if verbose else 100 if quiet else "INFO"
|
|
55
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
|
+
|
|
56
60
|
logger.remove()
|
|
57
|
-
logger.add(
|
|
61
|
+
logger.add(to_stdout, level=level, format=log_format, colorize=True)
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
for command in (app, ec2, emr, emr_all, healthcheck):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.5"
|
|
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
|