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.
Files changed (20) hide show
  1. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/PKG-INFO +8 -8
  2. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/README.md +114 -114
  3. aws_ssh_utils-0.2.7/aws_ssh_utils/__init__.py +1 -0
  4. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/cli_utils.py +53 -7
  5. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/app.py +12 -5
  6. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/connection.py +97 -53
  7. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/ssh.py +5 -1
  8. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/pyproject.toml +1 -0
  9. aws_ssh_utils-0.2.5/aws_ssh_utils/__init__.py +0 -1
  10. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/.gitignore +0 -0
  11. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/LICENSE +0 -0
  12. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/__init__.py +0 -0
  13. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/ec2.py +0 -0
  14. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/emr.py +0 -0
  15. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/emr_all.py +0 -0
  16. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/commands/healthcheck.py +0 -0
  17. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/ec2_utils.py +0 -0
  18. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/emr_utils.py +0 -0
  19. {aws_ssh_utils-0.2.5 → aws_ssh_utils-0.2.7}/aws_ssh_utils/py.typed +0 -0
  20. {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.5
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
  [![Supported Python Versions](https://img.shields.io/pypi/pyversions/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
42
42
 
43
43
  ```shell
44
- pip install aws-ssh-utils
44
+ uvx aws-ssh-utils
45
45
 
46
- aws_ssh ec2
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 emr
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
- [![Version](https://img.shields.io/pypi/v/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
4
- [![License](https://img.shields.io/pypi/l/aws-ssh-utils.svg)](#)
5
- [![Supported Python Versions](https://img.shields.io/pypi/pyversions/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
6
-
7
- ```shell
8
- pip install aws-ssh-utils
9
-
10
- aws_ssh ec2
11
-
12
- aws_ssh emr
13
-
14
- aws_ssh emr-all
15
-
16
- aws_ssh app
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
+ [![Version](https://img.shields.io/pypi/v/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
4
+ [![License](https://img.shields.io/pypi/l/aws-ssh-utils.svg)](#)
5
+ [![Supported Python Versions](https://img.shields.io/pypi/pyversions/aws-ssh-utils.svg)](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
- from collections.abc import Callable
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
- result = connect(target, env, on_output=logger.info, on_waiting=lambda: None, confirm_host_key=confirm_host_key)
149
- if isinstance(result, list):
150
- for line in result:
151
- console.print(escape(line))
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 isinstance(result, list):
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 | list[str]:
69
- """Return the first successful connection, or the failure checklist.
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
- if connection := attempt(
96
- f"ssh config ({alias})",
97
- f":x: Failed with ssh config - ssh {alias}",
98
- lambda: open_with_config(alias, options, target, confirm_host_key),
99
- ):
100
- return connection
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
- if not env.ssm_installed:
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
- ":x: Failed to start session with AWS session-manager",
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(ssm_command(target.instance_id, env), on_output),
121
+ sock=proxy_socket(ssm_proxy, on_output),
116
122
  ),
117
- ):
118
- return connection
119
-
120
- if target.opkssh_provider is not None:
121
- provider = target.opkssh_provider
122
- if not env.opkssh_installed:
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
- ":x: Failed to authenticate with opkssh",
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
- return connection
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
- if connection := attempt(
141
- "default key",
142
- f":x: Failed with default key - ssh {target.user}@{target.ip}",
143
- lambda: open_ssh_(target.ip, target.user, [], use_defaults=True),
144
- ):
145
- return connection
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
- return failures
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
- ['opkssh', 'login', '--provider', provider, '-i', str(key)], # noqa: S607
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(sys.stdout, level=level, format=log_format, colorize=True)
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):
@@ -60,6 +60,7 @@ Homepage = "https://github.com/mvanderlee/aws-ssh-utils"
60
60
 
61
61
  [project.scripts]
62
62
  aws_ssh = "aws_ssh_utils.ssh:cli"
63
+ aws-ssh-utils = "aws_ssh_utils.ssh:cli"
63
64
 
64
65
  [tool.hatch.build]
65
66
  include = [
@@ -1 +0,0 @@
1
- __version__ = "0.2.5"
File without changes
File without changes