aws-annoying 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.
@@ -1,8 +1,10 @@
1
1
  # TODO(lasuillard): Using this file until split CLI from library codebase
2
2
  from __future__ import annotations
3
3
 
4
+ import re
4
5
  from typing import Any
5
6
 
7
+ import boto3
6
8
  import typer
7
9
  from rich.prompt import Confirm
8
10
 
@@ -22,3 +24,31 @@ class SessionManager(_SessionManager):
22
24
  def install(self, *args: Any, confirm: bool = False, **kwargs: Any) -> None:
23
25
  self._confirm = confirm
24
26
  return super().install(*args, **kwargs)
27
+
28
+
29
+ def get_instance_id_by_name(name_or_id: str) -> str | None:
30
+ """Get the EC2 instance ID by name or ID.
31
+
32
+ Be aware that this function will only return the first instance found
33
+ with the given name, no matter how many instances are found.
34
+
35
+ Args:
36
+ name_or_id: The name or ID of the EC2 instance.
37
+
38
+ Returns:
39
+ The instance ID if found, otherwise `None`.
40
+ """
41
+ if re.match(r"m?i-.+", name_or_id):
42
+ return name_or_id
43
+
44
+ ec2 = boto3.client("ec2")
45
+ response = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [name_or_id]}])
46
+ reservations = response["Reservations"]
47
+ if not reservations:
48
+ return None
49
+
50
+ instances = reservations[0]["Instances"]
51
+ if not instances:
52
+ return None
53
+
54
+ return str(instances[0]["InstanceId"])
@@ -18,7 +18,7 @@ def install(
18
18
  ),
19
19
  ) -> None:
20
20
  """Install AWS Session Manager plugin."""
21
- session_manager = SessionManager(downloader=TQDMDownloader())
21
+ session_manager = SessionManager()
22
22
 
23
23
  # Check session-manager-plugin already installed
24
24
  is_installed, binary_path, version = session_manager.verify_installation()
@@ -28,7 +28,7 @@ def install(
28
28
 
29
29
  # Install session-manager-plugin
30
30
  print("⬇️ Installing AWS Session Manager plugin. You could be prompted for admin privileges request.")
31
- session_manager.install(confirm=yes)
31
+ session_manager.install(confirm=yes, downloader=TQDMDownloader())
32
32
 
33
33
  # Verify installation
34
34
  is_installed, binary_path, version = session_manager.verify_installation()
@@ -1,18 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- import re
5
4
  import signal
5
+ import subprocess
6
6
  from pathlib import Path # noqa: TC003
7
7
 
8
- import boto3
9
8
  import typer
10
9
  from rich import print # noqa: A004
11
10
 
12
- from aws_annoying.utils.downloader import TQDMDownloader
13
-
14
11
  from ._app import session_manager_app
15
- from ._common import SessionManager
12
+ from ._common import SessionManager, get_instance_id_by_name
16
13
 
17
14
 
18
15
  # https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
@@ -57,7 +54,7 @@ def port_forward( # noqa: PLR0913
57
54
  ),
58
55
  ) -> None:
59
56
  """Start a port forwarding session using AWS Session Manager."""
60
- session_manager = SessionManager(downloader=TQDMDownloader())
57
+ session_manager = SessionManager()
61
58
 
62
59
  # Check if the PID file already exists
63
60
  if pid_file.exists():
@@ -80,21 +77,16 @@ def port_forward( # noqa: PLR0913
80
77
  print(f"⚠️ Tried to terminate process with PID {existing_pid} but does not exist.")
81
78
 
82
79
  # Resolve the instance name or ID
83
- if re.match(r"m?i-.+", through):
84
- target = through
85
- else:
86
- # If the instance name is provided, get the instance ID
87
- instance_id = _get_instance_id_by_name(through)
88
- if instance_id:
89
- print(f"❗ Instance ID resolved: [bold]{instance_id}[/bold]")
90
- else:
91
- print(f"🚫 Instance with name '{through}' not found.")
92
- raise typer.Exit(1)
93
-
80
+ instance_id = get_instance_id_by_name(through)
81
+ if instance_id:
82
+ print(f"❗ Instance ID resolved: [bold]{instance_id}[/bold]")
94
83
  target = instance_id
84
+ else:
85
+ print(f"🚫 Instance with name '{through}' not found.")
86
+ raise typer.Exit(1)
95
87
 
96
88
  # Initiate the session
97
- proc = session_manager.start(
89
+ command = session_manager.build_command(
98
90
  target=target,
99
91
  document_name="AWS-StartPortForwardingSessionToRemoteHost",
100
92
  parameters={
@@ -104,23 +96,24 @@ def port_forward( # noqa: PLR0913
104
96
  },
105
97
  reason=reason,
106
98
  )
99
+ stdout: subprocess._FILE
100
+ if log_file is not None: # noqa: SIM108
101
+ stdout = log_file.open(mode="at+", buffering=1)
102
+ else:
103
+ stdout = subprocess.DEVNULL
104
+
105
+ print(
106
+ f"🚀 Starting port forwarding session through [bold]{through}[/bold] with reason: [italic]{reason!r}[/italic].",
107
+ )
108
+ proc = subprocess.Popen( # noqa: S603
109
+ command,
110
+ stdout=stdout,
111
+ stderr=subprocess.STDOUT,
112
+ text=True,
113
+ close_fds=False, # FD inherited from parent process
114
+ )
107
115
  print(f"✅ Session Manager Plugin started with PID {proc.pid}. Outputs will be logged to {log_file.absolute()}.")
108
116
 
109
117
  # Write the PID to the file
110
118
  pid_file.write_text(str(proc.pid))
111
119
  print(f"💾 PID file written to {pid_file.absolute()}.")
112
-
113
-
114
- def _get_instance_id_by_name(name: str) -> str | None:
115
- """Get the EC2 instance ID by name."""
116
- ec2 = boto3.client("ec2")
117
- response = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [name]}])
118
- reservations = response["Reservations"]
119
- if not reservations:
120
- return None
121
-
122
- instances = reservations[0]["Instances"]
123
- if not instances:
124
- return None
125
-
126
- return str(instances[0]["InstanceId"])
@@ -1,9 +1,47 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
4
+
5
+ import typer
6
+ from rich import print # noqa: A004
7
+
3
8
  from ._app import session_manager_app
9
+ from ._common import SessionManager, get_instance_id_by_name
10
+
11
+ # TODO(lasuillard): ECS support (#24)
12
+ # TODO(lasuillard): Interactive instance selection
4
13
 
5
14
 
6
15
  @session_manager_app.command()
7
- def start() -> None:
16
+ def start(
17
+ target: str = typer.Option(
18
+ ...,
19
+ show_default=False,
20
+ help="The name or ID of the EC2 instance to connect to.",
21
+ ),
22
+ reason: str = typer.Option(
23
+ "",
24
+ help="The reason for starting the session.",
25
+ ),
26
+ ) -> None:
8
27
  """Start new session."""
9
- # TODO(lasuillard): To be implemented (maybe in #24?)
28
+ session_manager = SessionManager()
29
+
30
+ # Resolve the instance name or ID
31
+ instance_id = get_instance_id_by_name(target)
32
+ if instance_id:
33
+ print(f"❗ Instance ID resolved: [bold]{instance_id}[/bold]")
34
+ target = instance_id
35
+ else:
36
+ print(f"🚫 Instance with name '{target}' not found.")
37
+ raise typer.Exit(1)
38
+
39
+ # Start the session, replacing the current process
40
+ print(f"🚀 Starting session to target [bold]{target}[/bold] with reason: [italic]{reason!r}[/italic].")
41
+ command = session_manager.build_command(
42
+ target=target,
43
+ document_name="SSM-SessionManagerRunShell",
44
+ parameters={},
45
+ reason=reason,
46
+ )
47
+ os.execvp(command[0], command) # noqa: S606
@@ -1,4 +1,11 @@
1
1
  from .errors import PluginNotInstalledError, SessionManagerError, UnsupportedPlatformError
2
2
  from .session_manager import SessionManager
3
+ from .shortcuts import port_forward
3
4
 
4
- __all__ = ("PluginNotInstalledError", "SessionManager", "SessionManagerError", "UnsupportedPlatformError")
5
+ __all__ = (
6
+ "PluginNotInstalledError",
7
+ "SessionManager",
8
+ "SessionManagerError",
9
+ "UnsupportedPlatformError",
10
+ "port_forward",
11
+ )
@@ -28,15 +28,13 @@ logger = logging.getLogger(__name__)
28
28
  class SessionManager:
29
29
  """AWS Session Manager plugin manager."""
30
30
 
31
- def __init__(self, *, session: boto3.session.Session | None = None, downloader: AbstractDownloader) -> None:
31
+ def __init__(self, *, session: boto3.session.Session | None = None) -> None:
32
32
  """Initialize SessionManager.
33
33
 
34
34
  Args:
35
35
  session: Boto3 session to use for AWS operations.
36
- downloader: File downloader to use for downloading the plugin.
37
36
  """
38
37
  self.session = session or boto3.session.Session()
39
- self.downloader = downloader
40
38
 
41
39
  # ------------------------------------------------------------------------
42
40
  # Installation
@@ -90,6 +88,7 @@ class SessionManager:
90
88
  linux_distribution: _LinuxDistribution | None = None,
91
89
  arch: str | None = None,
92
90
  root: bool | None = None,
91
+ downloader: AbstractDownloader,
93
92
  ) -> None:
94
93
  """Install AWS Session Manager plugin.
95
94
 
@@ -99,17 +98,23 @@ class SessionManager:
99
98
  If `None` and current `os` is `"Linux"`, will try to detect the distribution from current system.
100
99
  arch: The architecture to install the plugin on. If `None`, will use the current architecture.
101
100
  root: Whether to run the installation as root. If `None`, will check if the current user is root.
101
+ downloader: File downloader to use for downloading the plugin.
102
102
  """
103
103
  os = os or platform.system()
104
104
  arch = arch or platform.machine()
105
105
 
106
106
  if os == "Windows":
107
- self._install_windows()
107
+ self._install_windows(downloader=downloader)
108
108
  elif os == "Darwin":
109
- self._install_macos(arch=arch, root=root or is_root())
109
+ self._install_macos(arch=arch, root=root or is_root(), downloader=downloader)
110
110
  elif os == "Linux":
111
111
  linux_distribution = linux_distribution or _detect_linux_distribution()
112
- self._install_linux(linux_distribution=linux_distribution, arch=arch, root=root or is_root())
112
+ self._install_linux(
113
+ linux_distribution=linux_distribution,
114
+ arch=arch,
115
+ root=root or is_root(),
116
+ downloader=downloader,
117
+ )
113
118
  else:
114
119
  msg = f"Unsupported operating system: {os}"
115
120
  raise UnsupportedPlatformError(msg)
@@ -118,20 +123,20 @@ class SessionManager:
118
123
  """Hook to run before invoking plugin installation command."""
119
124
 
120
125
  # https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-windows.html
121
- def _install_windows(self) -> None:
126
+ def _install_windows(self, *, downloader: AbstractDownloader) -> None:
122
127
  """Install session-manager-plugin on Windows via EXE installer."""
123
128
  download_url = (
124
129
  "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/windows/SessionManagerPluginSetup.exe"
125
130
  )
126
131
  with tempfile.TemporaryDirectory() as temp_dir:
127
132
  p = Path(temp_dir)
128
- exe_installer = self.downloader.download(download_url, to=p / "SessionManagerPluginSetup.exe")
133
+ exe_installer = downloader.download(download_url, to=p / "SessionManagerPluginSetup.exe")
129
134
  command = [str(exe_installer), "/quiet"]
130
135
  self.before_install(command)
131
136
  subprocess.call(command, cwd=p) # noqa: S603
132
137
 
133
138
  # https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-macos-overview.html
134
- def _install_macos(self, *, arch: str, root: bool) -> None:
139
+ def _install_macos(self, *, arch: str, root: bool, downloader: AbstractDownloader) -> None:
135
140
  """Install session-manager-plugin on macOS via signed installer."""
136
141
  # ! Intel chip will not be supported
137
142
  if arch == "x86_64":
@@ -148,7 +153,7 @@ class SessionManager:
148
153
 
149
154
  with tempfile.TemporaryDirectory() as temp_dir:
150
155
  p = Path(temp_dir)
151
- pkg_installer = self.downloader.download(download_url, to=p / "session-manager-plugin.pkg")
156
+ pkg_installer = downloader.download(download_url, to=p / "session-manager-plugin.pkg")
152
157
 
153
158
  # Run installer
154
159
  command = command_as_root(
@@ -179,6 +184,7 @@ class SessionManager:
179
184
  linux_distribution: _LinuxDistribution,
180
185
  arch: str,
181
186
  root: bool,
187
+ downloader: AbstractDownloader,
182
188
  ) -> None:
183
189
  name = linux_distribution.name
184
190
  version = linux_distribution.version
@@ -200,7 +206,7 @@ class SessionManager:
200
206
 
201
207
  with tempfile.TemporaryDirectory() as temp_dir:
202
208
  p = Path(temp_dir)
203
- deb_installer = self.downloader.download(download_url, to=p / "session-manager-plugin.deb")
209
+ deb_installer = downloader.download(download_url, to=p / "session-manager-plugin.deb")
204
210
 
205
211
  # Invoke installation command
206
212
  command = command_as_root(["dpkg", "--install", str(deb_installer)], root=root)
@@ -241,28 +247,25 @@ class SessionManager:
241
247
  raise UnsupportedPlatformError(msg)
242
248
 
243
249
  # ------------------------------------------------------------------------
244
- # Session
250
+ # Command
245
251
  # ------------------------------------------------------------------------
246
- def start(
252
+ def build_command(
247
253
  self,
248
- *,
249
254
  target: str,
250
255
  document_name: str,
251
256
  parameters: dict[str, Any],
252
257
  reason: str | None = None,
253
- log_file: Path | None = None,
254
- ) -> subprocess.Popen:
255
- """Start new session.
258
+ ) -> list[str]:
259
+ """Build command for starting a session.
256
260
 
257
261
  Args:
258
- target: The target instance ID or name.
262
+ target: The target instance ID.
259
263
  document_name: The SSM document name to use for the session.
260
264
  parameters: The parameters to pass to the SSM document.
261
265
  reason: The reason for starting the session.
262
- log_file: Optional file to log output to.
263
266
 
264
267
  Returns:
265
- Process ID of the session.
268
+ The command to start the session.
266
269
  """
267
270
  is_installed, binary_path, version = self.verify_installation()
268
271
  if not is_installed:
@@ -279,7 +282,7 @@ class SessionManager:
279
282
  )
280
283
 
281
284
  region = self.session.region_name
282
- command = [
285
+ return [
283
286
  str(binary_path),
284
287
  json.dumps(response),
285
288
  region,
@@ -289,20 +292,6 @@ class SessionManager:
289
292
  f"https://ssm.{region}.amazonaws.com",
290
293
  ]
291
294
 
292
- stdout: subprocess._FILE
293
- if log_file is not None: # noqa: SIM108
294
- stdout = log_file.open(mode="at+", buffering=1)
295
- else:
296
- stdout = subprocess.DEVNULL
297
-
298
- return subprocess.Popen( # noqa: S603
299
- command,
300
- stdout=stdout,
301
- stderr=subprocess.STDOUT,
302
- text=True,
303
- close_fds=False, # FD inherited from parent process
304
- )
305
-
306
295
 
307
296
  # ? Could be moved to utils, but didn't because it's too specific to this module
308
297
  class _LinuxDistribution(NamedTuple):
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ from contextlib import contextmanager
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .session_manager import SessionManager
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterator
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @contextmanager
18
+ def port_forward(
19
+ *,
20
+ through: str,
21
+ local_port: int,
22
+ remote_host: str,
23
+ remote_port: int,
24
+ reason: str | None = None,
25
+ ) -> Iterator[subprocess.Popen[str]]:
26
+ """Context manager for port forwarding sessions.
27
+
28
+ Args:
29
+ through: The instance ID to use as port-forwarding proxy.
30
+ local_port: The local port to listen to.
31
+ remote_host: The remote host to connect to.
32
+ remote_port: The remote port to connect to.
33
+ reason: The reason for starting the session.
34
+
35
+ Returns:
36
+ The command to start the session.
37
+ """
38
+ session_manager = SessionManager()
39
+ command = session_manager.build_command(
40
+ target=through,
41
+ document_name="AWS-StartPortForwardingSessionToRemoteHost",
42
+ parameters={
43
+ "localPortNumber": [str(local_port)],
44
+ "host": [remote_host],
45
+ "portNumber": [str(remote_port)],
46
+ },
47
+ reason=reason,
48
+ )
49
+ try:
50
+ proc = subprocess.Popen( # noqa: S603
51
+ command,
52
+ stdout=subprocess.PIPE,
53
+ stderr=subprocess.STDOUT,
54
+ text=True,
55
+ )
56
+
57
+ # * Must be unreachable
58
+ if proc.stdout is None:
59
+ msg = "Standard output is not available"
60
+ raise RuntimeError(msg)
61
+
62
+ # Wait for the session to start
63
+ # ? Not sure this is trustworthy health check
64
+ # TODO(lasuillard): Need timeout to avoid hanging forever
65
+ for line in proc.stdout:
66
+ if "Waiting for connections..." in line:
67
+ logger.info("Session started successfully.")
68
+ break
69
+
70
+ yield proc
71
+ finally:
72
+ proc.terminate()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-annoying
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Utils to handle some annoying AWS tasks.
5
5
  Project-URL: Homepage, https://github.com/lasuillard/aws-annoying
6
6
  Project-URL: Repository, https://github.com/lasuillard/aws-annoying.git
@@ -11,20 +11,21 @@ aws_annoying/cli/mfa/_app.py,sha256=Ub7gxb6kGF3Ve1ucQSOjHmc4jAu8mxgegcXsIbOzLLQ,
11
11
  aws_annoying/cli/mfa/configure.py,sha256=vsoHfTVFF2dPgiYsp2L-EkMwtAA0_-tVwFd6Wv6DscU,3746
12
12
  aws_annoying/cli/session_manager/__init__.py,sha256=FkT6jT6OXduOURN61d-U6hgd-XluQbvuVtKXXiXgSEk,105
13
13
  aws_annoying/cli/session_manager/_app.py,sha256=OVOHW0iyKzunvaqLhjoseHw1-WxJ1gGb7QmiyAEezyY,221
14
- aws_annoying/cli/session_manager/_common.py,sha256=0fCm6Zx6P1NcyyiHlSoB9PgIdrxzUXLVPqKJWQnJ8I4,792
15
- aws_annoying/cli/session_manager/install.py,sha256=zX2cu-IBYlf9yl8z9P0CTQz72BP6aAMo-KnolC_PGsU,1443
16
- aws_annoying/cli/session_manager/port_forward.py,sha256=uMcsafTNAHZh-0E1yWXCliMivWyuWV2K9DdVhBZ9pG8,4327
17
- aws_annoying/cli/session_manager/start.py,sha256=1yaMuy-7IWrrDoPHjgygOP_-g_tajfnpaVPZanxsZxs,215
14
+ aws_annoying/cli/session_manager/_common.py,sha256=u23F4mJOHWHphLYL1gOAh8J3a_Odyk4VVvI175KWmzg,1616
15
+ aws_annoying/cli/session_manager/install.py,sha256=zcQi91xVFKhbSOD4VBc6YG9-fDnhymVyK63PlITdxug,1445
16
+ aws_annoying/cli/session_manager/port_forward.py,sha256=J8_CIrTsbcOYRYOdHkdz81dkaNWxI_l70E8gyJ-Ukh8,4192
17
+ aws_annoying/cli/session_manager/start.py,sha256=pPS0jKuURGTX-WTix3owqqisX-bmCydqfyqC0kFGnt8,1358
18
18
  aws_annoying/cli/session_manager/stop.py,sha256=ttU6nlbVgBkZDtY-DwUyCstv5TFtat5TljkyuY8QICU,1482
19
- aws_annoying/session_manager/__init__.py,sha256=swbRdFh9CdO6tzyBjSxN0KC7MMASvptIpbsijUPIZgI,243
19
+ aws_annoying/session_manager/__init__.py,sha256=IENviL3ux2LF7o9xFGYEiqaGw03hxnyNX2btbB1xyEU,318
20
20
  aws_annoying/session_manager/errors.py,sha256=YioKlRtZ-GUP0F_ts_ebw7-HYkxe8mTes6HK821Kuiw,353
21
- aws_annoying/session_manager/session_manager.py,sha256=7tqfFPA4bWF0Me_VbUDcq5Cpl1sw3bevGyT8QWfLTOk,12454
21
+ aws_annoying/session_manager/session_manager.py,sha256=myZxY_WE4akdlTsH1mOvf0Ublwg-hf1vEkEcmdZyYSU,12147
22
+ aws_annoying/session_manager/shortcuts.py,sha256=uFRPGia_5gqfBDxwOjmLg7UFzhvkSFUqopWuzN5_kbA,1973
22
23
  aws_annoying/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
24
  aws_annoying/utils/debugger.py,sha256=UFllDCGI2gPtwo1XS5vqw0qyR6bYr7XknmBwSxalKIc,754
24
25
  aws_annoying/utils/downloader.py,sha256=aB5RzT-LpbFX24-2HXlAkdgVowc4TR9FWT_K8WwZ1BE,1923
25
26
  aws_annoying/utils/platform.py,sha256=h3DUWmTMM-_4TfTWNqY0uNqyVsBjAuMm2DEbG-daxe8,742
26
- aws_annoying-0.4.0.dist-info/METADATA,sha256=9u5wofuLZXXPrAj1YcOhbqPlPH46jtub8AUvs2KNZ9s,1916
27
- aws_annoying-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
28
- aws_annoying-0.4.0.dist-info/entry_points.txt,sha256=DcKE5V0WvVJ8wUOHxyUz1yLAJOuuJUgRPlMcQ4O7jEs,66
29
- aws_annoying-0.4.0.dist-info/licenses/LICENSE,sha256=Q5GkvYijQ2KTQ-QWhv43ilzCno4ZrzrEuATEQZd9rYo,1067
30
- aws_annoying-0.4.0.dist-info/RECORD,,
27
+ aws_annoying-0.5.0.dist-info/METADATA,sha256=kHaGAHqfkZ8Ip4NzcLbE7Bh01oA1CvoTsUa3Ljx1ctU,1916
28
+ aws_annoying-0.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ aws_annoying-0.5.0.dist-info/entry_points.txt,sha256=DcKE5V0WvVJ8wUOHxyUz1yLAJOuuJUgRPlMcQ4O7jEs,66
30
+ aws_annoying-0.5.0.dist-info/licenses/LICENSE,sha256=Q5GkvYijQ2KTQ-QWhv43ilzCno4ZrzrEuATEQZd9rYo,1067
31
+ aws_annoying-0.5.0.dist-info/RECORD,,