aws-annoying 0.2.0__py3-none-any.whl → 0.3.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,11 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  import boto3
4
6
  import typer
5
7
  from rich import print # noqa: A004
6
8
 
7
9
  from .app import app
8
10
 
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+
14
+ _DELETE_CHUNK_SIZE = 10
15
+
9
16
 
10
17
  @app.command()
11
18
  def ecs_task_definition_lifecycle(
@@ -22,6 +29,10 @@ def ecs_task_definition_lifecycle(
22
29
  min=1,
23
30
  max=100,
24
31
  ),
32
+ delete: bool = typer.Option(
33
+ False, # noqa: FBT003
34
+ help="Delete the task definition after deregistering it.",
35
+ ),
25
36
  dry_run: bool = typer.Option(
26
37
  False, # noqa: FBT003
27
38
  help="Do not perform any changes, only show what would be done.",
@@ -48,6 +59,7 @@ def ecs_task_definition_lifecycle(
48
59
 
49
60
  # Keep the latest N task definitions
50
61
  expired_taskdef_arns = task_definition_arns[:-keep_latest]
62
+ print(f"⚠️ Deregistering {len(expired_taskdef_arns)} task definitions...")
51
63
  for arn in expired_taskdef_arns:
52
64
  if not dry_run:
53
65
  ecs.deregister_task_definition(taskDefinition=arn)
@@ -55,3 +67,18 @@ def ecs_task_definition_lifecycle(
55
67
  # ARN like: "arn:aws:ecs:<region>:<account-id>:task-definition/<family>:<revision>"
56
68
  _, family_revision = arn.split(":task-definition/")
57
69
  print(f"✅ Deregistered task definition [yellow]{family_revision!r}[/yellow]")
70
+
71
+ if delete and expired_taskdef_arns:
72
+ # Delete the expired task definitions in chunks due to API limitation
73
+ print(f"⚠️ Deleting {len(expired_taskdef_arns)} task definitions in chunks of size {_DELETE_CHUNK_SIZE}...")
74
+ for idx, chunk in enumerate(_chunker(expired_taskdef_arns, _DELETE_CHUNK_SIZE)):
75
+ if not dry_run:
76
+ ecs.delete_task_definitions(taskDefinitions=chunk)
77
+
78
+ print(f"✅ Deleted {len(chunk)} task definitions in {idx}-th batch.")
79
+
80
+
81
+ def _chunker(sequence: list, size: int) -> Iterator[list]:
82
+ """Yield successive chunks of a given size from the sequence."""
83
+ for i in range(0, len(sequence), size):
84
+ yield sequence[i : i + size]
aws_annoying/main.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import aws_annoying.ecs_task_definition_lifecycle
5
5
  import aws_annoying.load_variables
6
6
  import aws_annoying.mfa
7
+ import aws_annoying.session_manager
7
8
  from aws_annoying.utils.debugger import input_as_args
8
9
 
9
10
  # App with all commands registered
@@ -0,0 +1,3 @@
1
+ from . import install, port_forward, start, stop
2
+
3
+ __all__ = ("install", "port_forward", "start", "stop")
@@ -0,0 +1,9 @@
1
+ import typer
2
+
3
+ from aws_annoying.app import app
4
+
5
+ session_manager_app = typer.Typer(
6
+ no_args_is_help=True,
7
+ help="AWS Session Manager CLI utilities.",
8
+ )
9
+ app.add_typer(session_manager_app, name="session-manager")
@@ -0,0 +1,24 @@
1
+ # TODO(lasuillard): Using this file until split CLI from library codebase
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import typer
7
+ from rich.prompt import Confirm
8
+
9
+ from .session_manager import SessionManager as _SessionManager
10
+
11
+
12
+ # Custom session manager with console interactivity
13
+ class SessionManager(_SessionManager):
14
+ def before_install(self, command: list[str]) -> None:
15
+ if self._confirm:
16
+ return
17
+
18
+ confirm = Confirm.ask(f"⚠️ Will run the following command: [bold red]{' '.join(command)}[/bold red]. Proceed?")
19
+ if not confirm:
20
+ raise typer.Abort
21
+
22
+ def install(self, *args: Any, confirm: bool = False, **kwargs: Any) -> None:
23
+ self._confirm = confirm
24
+ return super().install(*args, **kwargs)
@@ -0,0 +1,10 @@
1
+ class SessionManagerError(Exception):
2
+ """Base exception for all errors related to Session Manager."""
3
+
4
+
5
+ class UnsupportedPlatformError(SessionManagerError):
6
+ """Exception raised when the platform is not supported."""
7
+
8
+
9
+ class PluginNotInstalledError(SessionManagerError):
10
+ """Trying to use the Session Manager plugin before it is installed."""
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich import print # noqa: A004
5
+
6
+ from aws_annoying.utils.downloader import TQDMDownloader
7
+
8
+ from ._app import session_manager_app
9
+ from ._common import SessionManager
10
+
11
+
12
+ # https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
13
+ @session_manager_app.command()
14
+ def install(
15
+ yes: bool = typer.Option( # noqa: FBT001
16
+ False, # noqa: FBT003
17
+ help="Do not ask confirmation for installation.",
18
+ ),
19
+ ) -> None:
20
+ """Install AWS Session Manager plugin."""
21
+ session_manager = SessionManager(downloader=TQDMDownloader())
22
+
23
+ # Check session-manager-plugin already installed
24
+ is_installed, binary_path, version = session_manager.verify_installation()
25
+ if is_installed:
26
+ print(f"✅ Session Manager plugin is already installed at {binary_path} (version: {version})")
27
+ return
28
+
29
+ # Install session-manager-plugin
30
+ print("⬇️ Installing AWS Session Manager plugin. You could be prompted for admin privileges request.")
31
+ session_manager.install(confirm=yes)
32
+
33
+ # Verify installation
34
+ is_installed, binary_path, version = session_manager.verify_installation()
35
+ if not is_installed:
36
+ print("❌ Installation failed. Session Manager plugin not found.")
37
+ raise typer.Exit(1)
38
+
39
+ print(f"✅ Session Manager plugin successfully installed at {binary_path} (version: {version})")
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import signal
6
+ from pathlib import Path # noqa: TC003
7
+
8
+ import boto3
9
+ import typer
10
+ from rich import print # noqa: A004
11
+
12
+ from aws_annoying.utils.downloader import TQDMDownloader
13
+
14
+ from ._app import session_manager_app
15
+ from ._common import SessionManager
16
+
17
+
18
+ # https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
19
+ @session_manager_app.command()
20
+ def port_forward( # noqa: PLR0913
21
+ # TODO(lasuillard): Add `--local-host` option, redirect the traffic to non-localhost bind (unsupported by AWS)
22
+ local_port: int = typer.Option(
23
+ ...,
24
+ show_default=False,
25
+ help="The local port to use for port forwarding.",
26
+ ),
27
+ through: str = typer.Option(
28
+ ...,
29
+ show_default=False,
30
+ help="The name or ID of the EC2 instance to use as a proxy for port forwarding.",
31
+ ),
32
+ remote_host: str = typer.Option(
33
+ ...,
34
+ show_default=False,
35
+ help="The remote host to connect to.",
36
+ ),
37
+ remote_port: int = typer.Option(
38
+ ...,
39
+ show_default=False,
40
+ help="The remote port to connect to.",
41
+ ),
42
+ reason: str = typer.Option(
43
+ "",
44
+ help="The reason for starting the port forwarding session.",
45
+ ),
46
+ pid_file: Path = typer.Option( # noqa: B008
47
+ "./session-manager-plugin.pid",
48
+ help="The path to the PID file to store the process ID of the session manager plugin.",
49
+ ),
50
+ terminate_running_process: bool = typer.Option( # noqa: FBT001
51
+ False, # noqa: FBT003
52
+ help="Terminate the process in the PID file if it already exists.",
53
+ ),
54
+ log_file: Path = typer.Option( # noqa: B008
55
+ "./session-manager-plugin.log",
56
+ help="The path to the log file to store the output of the session manager plugin.",
57
+ ),
58
+ ) -> None:
59
+ """Start a port forwarding session using AWS Session Manager."""
60
+ session_manager = SessionManager(downloader=TQDMDownloader())
61
+
62
+ # Check if the PID file already exists
63
+ if pid_file.exists():
64
+ if not terminate_running_process:
65
+ print("🚫 PID file already exists.")
66
+ raise typer.Exit(1)
67
+
68
+ pid_content = pid_file.read_text()
69
+ try:
70
+ existing_pid = int(pid_content)
71
+ except ValueError:
72
+ print(f"🚫 PID file content is invalid; expected integer, but got: {type(pid_content)}")
73
+ raise typer.Exit(1) from None
74
+
75
+ try:
76
+ print(f"⚠️ Terminating running process with PID {existing_pid}.")
77
+ os.kill(existing_pid, signal.SIGTERM)
78
+ pid_file.write_text("") # Clear the PID file
79
+ except ProcessLookupError:
80
+ print(f"⚠️ Tried to terminate process with PID {existing_pid} but does not exist.")
81
+
82
+ # 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
+
94
+ target = instance_id
95
+
96
+ # Initiate the session
97
+ proc = session_manager.start(
98
+ target=target,
99
+ document_name="AWS-StartPortForwardingSessionToRemoteHost",
100
+ parameters={
101
+ "host": [remote_host],
102
+ "portNumber": [str(remote_port)],
103
+ "localPortNumber": [str(local_port)],
104
+ },
105
+ reason=reason,
106
+ )
107
+ print(f"✅ Session Manager Plugin started with PID {proc.pid}. Outputs will be logged to {log_file.absolute()}.")
108
+
109
+ # Write the PID to the file
110
+ pid_file.write_text(str(proc.pid))
111
+ 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"])
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import platform
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, NamedTuple
13
+
14
+ import boto3
15
+
16
+ from aws_annoying.utils.platform import command_as_root, is_root, os_release
17
+
18
+ from .errors import PluginNotInstalledError, UnsupportedPlatformError
19
+
20
+ if TYPE_CHECKING:
21
+ from aws_annoying.utils.downloader import AbstractDownloader
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # TODO(lasuillard): Platform checking is spread everywhere, should be moved to a single place
26
+
27
+
28
+ class SessionManager:
29
+ """AWS Session Manager plugin manager."""
30
+
31
+ def __init__(self, *, session: boto3.session.Session | None = None, downloader: AbstractDownloader) -> None:
32
+ """Initialize SessionManager.
33
+
34
+ Args:
35
+ session: Boto3 session to use for AWS operations.
36
+ downloader: File downloader to use for downloading the plugin.
37
+ """
38
+ self.session = session or boto3.session.Session()
39
+ self.downloader = downloader
40
+
41
+ # ------------------------------------------------------------------------
42
+ # Installation
43
+ # ------------------------------------------------------------------------
44
+ def verify_installation(self) -> tuple[bool, Path | None, str | None]:
45
+ """Verify installation of AWS Session Manager plugin.
46
+
47
+ Returns:
48
+ 3-tuple of boolean flag indicating whether plugin installed, binary path and version string.
49
+ """
50
+ # Find plugin binary
51
+ if not (binary_path := self._get_binary_path()):
52
+ return False, None, None
53
+
54
+ # Check version
55
+ result_bytes = subprocess.run( # noqa: S603
56
+ [str(binary_path), "--version"],
57
+ check=True,
58
+ capture_output=True,
59
+ )
60
+ result = result_bytes.stdout.decode().strip()
61
+ if not bool(re.match(r"[\d\.]+", result)):
62
+ return False, binary_path, result
63
+
64
+ return True, binary_path, result
65
+
66
+ def _get_binary_path(self) -> Path | None:
67
+ """Get the path to the session-manager-plugin binary."""
68
+ binary_path_str = shutil.which("session-manager-plugin")
69
+ if not binary_path_str:
70
+ if platform.system() == "Windows":
71
+ # Windows: use the default installation path
72
+ binary_path = (
73
+ Path(os.environ["ProgramFiles"]) # noqa: SIM112
74
+ / "Amazon"
75
+ / "SessionManagerPlugin"
76
+ / "bin"
77
+ / "session-manager-plugin.exe"
78
+ )
79
+ if binary_path.is_file():
80
+ return binary_path.absolute()
81
+
82
+ return None
83
+
84
+ return Path(binary_path_str).absolute()
85
+
86
+ def install(
87
+ self,
88
+ *,
89
+ os: str | None = None,
90
+ linux_distribution: _LinuxDistribution | None = None,
91
+ arch: str | None = None,
92
+ root: bool | None = None,
93
+ ) -> None:
94
+ """Install AWS Session Manager plugin.
95
+
96
+ Args:
97
+ os: The operating system to install the plugin on. If `None`, will use the current operating system.
98
+ linux_distribution: The Linux distribution to install the plugin on.
99
+ If `None` and current `os` is `"Linux"`, will try to detect the distribution from current system.
100
+ arch: The architecture to install the plugin on. If `None`, will use the current architecture.
101
+ root: Whether to run the installation as root. If `None`, will check if the current user is root.
102
+ """
103
+ os = os or platform.system()
104
+ arch = arch or platform.machine()
105
+
106
+ if os == "Windows":
107
+ self._install_windows()
108
+ elif os == "Darwin":
109
+ self._install_macos(arch=arch, root=root or is_root())
110
+ elif os == "Linux":
111
+ linux_distribution = linux_distribution or _detect_linux_distribution()
112
+ self._install_linux(linux_distribution=linux_distribution, arch=arch, root=root or is_root())
113
+ else:
114
+ msg = f"Unsupported operating system: {os}"
115
+ raise UnsupportedPlatformError(msg)
116
+
117
+ def before_install(self, command: list[str]) -> None:
118
+ """Hook to run before invoking plugin installation command."""
119
+
120
+ # https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-windows.html
121
+ def _install_windows(self) -> None:
122
+ """Install session-manager-plugin on Windows via EXE installer."""
123
+ download_url = (
124
+ "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/windows/SessionManagerPluginSetup.exe"
125
+ )
126
+ with tempfile.TemporaryDirectory() as temp_dir:
127
+ p = Path(temp_dir)
128
+ exe_installer = self.downloader.download(download_url, to=p / "SessionManagerPluginSetup.exe")
129
+ command = [str(exe_installer), "/quiet"]
130
+ self.before_install(command)
131
+ subprocess.call(command, cwd=p) # noqa: S603
132
+
133
+ # https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-macos-overview.html
134
+ def _install_macos(self, *, arch: str, root: bool) -> None:
135
+ """Install session-manager-plugin on macOS via signed installer."""
136
+ # ! Intel chip will not be supported
137
+ if arch == "x86_64":
138
+ download_url = (
139
+ "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac/session-manager-plugin.pkg"
140
+ )
141
+ elif arch == "arm64":
142
+ download_url = (
143
+ "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac_arm64/session-manager-plugin.pkg"
144
+ )
145
+ else:
146
+ msg = f"Architecture {arch} not supported for macOS"
147
+ raise UnsupportedPlatformError(msg)
148
+
149
+ with tempfile.TemporaryDirectory() as temp_dir:
150
+ p = Path(temp_dir)
151
+ pkg_installer = self.downloader.download(download_url, to=p / "session-manager-plugin.pkg")
152
+
153
+ # Run installer
154
+ command = command_as_root(
155
+ ["installer", "-pkg", str(pkg_installer), "-target", "/"],
156
+ root=root,
157
+ )
158
+ self.before_install(command)
159
+ subprocess.call(command, cwd=p) # noqa: S603
160
+
161
+ # Symlink
162
+ command = [
163
+ "ln",
164
+ "-s",
165
+ "/usr/local/sessionmanagerplugin/bin/session-manager-plugin",
166
+ "/usr/local/bin/session-manager-plugin",
167
+ ]
168
+ if not root:
169
+ command = ["sudo", *command]
170
+
171
+ logger.info("Running %s to create symlink", " ".join(command))
172
+ Path("/usr/local/bin").mkdir(exist_ok=True)
173
+ subprocess.call(command, cwd=p) # noqa: S603
174
+
175
+ # https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-linux-overview.html
176
+ def _install_linux(
177
+ self,
178
+ *,
179
+ linux_distribution: _LinuxDistribution,
180
+ arch: str,
181
+ root: bool,
182
+ ) -> None:
183
+ name = linux_distribution.name
184
+ version = linux_distribution.version
185
+
186
+ # Debian / Ubuntu
187
+ if name in ("debian", "ubuntu"):
188
+ logger.info("Detected Linux distribution: Debian / Ubuntu")
189
+
190
+ # Download installer
191
+ url_map = {
192
+ "x86_64": "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb",
193
+ "x86": "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_32bit/session-manager-plugin.deb",
194
+ "arm64": "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_arm64/session-manager-plugin.deb",
195
+ }
196
+ download_url = url_map.get(arch)
197
+ if not download_url:
198
+ msg = f"Architecture {arch} not supported for distribution {name}"
199
+ raise UnsupportedPlatformError(msg)
200
+
201
+ with tempfile.TemporaryDirectory() as temp_dir:
202
+ p = Path(temp_dir)
203
+ deb_installer = self.downloader.download(download_url, to=p / "session-manager-plugin.deb")
204
+
205
+ # Invoke installation command
206
+ command = command_as_root(["dpkg", "--install", str(deb_installer)], root=root)
207
+ self.before_install(command)
208
+ subprocess.call(command, cwd=p) # noqa: S603
209
+
210
+ # Amazon Linux / RHEL
211
+ elif name in ("amzn", "rhel"):
212
+ logger.info("Detected Linux distribution: Amazon Linux / RHEL")
213
+
214
+ # Determine package URL and package manager
215
+ url_map = {
216
+ "x86_64": "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_64bit/session-manager-plugin.rpm",
217
+ "x86": "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_32bit/session-manager-plugin.rpm",
218
+ "arm64": "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_arm64/session-manager-plugin.rpm",
219
+ }
220
+ package_url = url_map.get(arch)
221
+ if not package_url:
222
+ msg = f"Architecture {arch} not supported for distribution {name}"
223
+ raise UnsupportedPlatformError(msg)
224
+
225
+ use_yum = (
226
+ # Amazon Linux 2
227
+ name == "amzn" and version.startswith("2")
228
+ ) or (
229
+ # RHEL 7 Maipo (8 Ootpa / 9 Plow)
230
+ name == "rhel" and "Maipo" in version
231
+ ) # ... else use dnf
232
+ package_manager = "yum" if use_yum else "dnf"
233
+
234
+ # Invoke installation command
235
+ command = command_as_root([package_manager, "install", "-y", package_url], root=root)
236
+ self.before_install(command)
237
+ subprocess.call(command) # noqa: S603
238
+
239
+ else:
240
+ msg = f"Unsupported distribution: {name}"
241
+ raise UnsupportedPlatformError(msg)
242
+
243
+ # ------------------------------------------------------------------------
244
+ # Session
245
+ # ------------------------------------------------------------------------
246
+ def start(
247
+ self,
248
+ *,
249
+ target: str,
250
+ document_name: str,
251
+ parameters: dict[str, Any],
252
+ reason: str | None = None,
253
+ log_file: Path | None = None,
254
+ ) -> subprocess.Popen:
255
+ """Start new session.
256
+
257
+ Args:
258
+ target: The target instance ID or name.
259
+ document_name: The SSM document name to use for the session.
260
+ parameters: The parameters to pass to the SSM document.
261
+ reason: The reason for starting the session.
262
+ log_file: Optional file to log output to.
263
+
264
+ Returns:
265
+ Process ID of the session.
266
+ """
267
+ is_installed, binary_path, version = self.verify_installation()
268
+ if not is_installed:
269
+ msg = "Session Manager plugin is not installed."
270
+ raise PluginNotInstalledError(msg)
271
+
272
+ ssm = self.session.client("ssm")
273
+ response = ssm.start_session(
274
+ Target=target,
275
+ DocumentName=document_name,
276
+ Parameters=parameters,
277
+ # ? Reason is optional but it doesn't allow empty string or `None`
278
+ **({"Reason": reason} if reason else {}),
279
+ )
280
+
281
+ region = self.session.region_name
282
+ command = [
283
+ str(binary_path),
284
+ json.dumps(response),
285
+ region,
286
+ "StartSession",
287
+ self.session.profile_name,
288
+ json.dumps({"Target": target}),
289
+ f"https://ssm.{region}.amazonaws.com",
290
+ ]
291
+
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
+
307
+ # ? Could be moved to utils, but didn't because it's too specific to this module
308
+ class _LinuxDistribution(NamedTuple):
309
+ name: str
310
+ version: str
311
+
312
+
313
+ def _detect_linux_distribution() -> _LinuxDistribution:
314
+ """Autodetect current Linux distribution."""
315
+ osr = os_release()
316
+ name = osr.get("ID", "").lower()
317
+ version = osr.get("VERSION", "")
318
+ return _LinuxDistribution(name=name, version=version)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from ._app import session_manager_app
4
+
5
+
6
+ @session_manager_app.command()
7
+ def start() -> None:
8
+ """Start new session."""
9
+ # TODO(lasuillard): To be implemented (maybe in #24?)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import signal
5
+ from pathlib import Path # noqa: TC003
6
+
7
+ import typer
8
+ from rich import print # noqa: A004
9
+
10
+ from ._app import session_manager_app
11
+
12
+
13
+ @session_manager_app.command()
14
+ def stop(
15
+ pid_file: Path = typer.Option( # noqa: B008
16
+ "./session-manager-plugin.pid",
17
+ help="The path to the PID file to store the process ID of the session manager plugin.",
18
+ ),
19
+ remove: bool = typer.Option( # noqa: FBT001
20
+ True, # noqa: FBT003
21
+ help="Remove the PID file after stopping the session.",
22
+ ),
23
+ ) -> None:
24
+ """Stop running session for PID file."""
25
+ # Check if PID file exists
26
+ if not pid_file.is_file():
27
+ print(f"❌ PID file not found: {pid_file}")
28
+ raise typer.Exit(1)
29
+
30
+ # Read PID from file
31
+ pid_content = pid_file.read_text()
32
+ try:
33
+ pid = int(pid_content)
34
+ except ValueError:
35
+ print(f"🚫 PID file content is invalid; expected integer, but got: {type(pid_content)}")
36
+ raise typer.Exit(1) from None
37
+
38
+ # Send SIGTERM to the process
39
+ try:
40
+ print(f"⚠️ Terminating running process with PID {pid}.")
41
+ os.kill(pid, signal.SIGTERM)
42
+ except ProcessLookupError:
43
+ print(f"❗ Tried to terminate process with PID {pid} but does not exist.")
44
+
45
+ # Remove the PID file
46
+ if remove:
47
+ print(f"✅ Removed the PID file {pid_file}.")
48
+ pid_file.unlink()
49
+
50
+ print("✅ Terminated the session successfully.")
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from typing import TYPE_CHECKING
6
+
7
+ import requests
8
+ from tqdm import tqdm
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class AbstractDownloader(ABC):
17
+ """Abstract downloader class for downloading files."""
18
+
19
+ @abstractmethod
20
+ def download(self, url: str, *, to: Path) -> Path:
21
+ """Download file from URL to path."""
22
+
23
+
24
+ class DummyDownloader(AbstractDownloader):
25
+ """Dummy downloader that does nothing (mainly for testing purposes)."""
26
+
27
+ def download(self, url: str, *, to: Path) -> Path:
28
+ """Download file from URL to path."""
29
+ logger.debug("Dummy downloader called for URL (%s) to %s.", url, to)
30
+ return to.absolute()
31
+
32
+
33
+ class TQDMDownloader(AbstractDownloader):
34
+ """Downloader with TQDM progress bar."""
35
+
36
+ def download(self, url: str, *, to: Path) -> Path:
37
+ """Download file from URL to path."""
38
+ # https://gist.github.com/yanqd0/c13ed29e29432e3cf3e7c38467f42f51
39
+ logger.info("Downloading file from URL (%s) to %s.", url, to)
40
+ with requests.get(url, stream=True, timeout=10) as response:
41
+ response.raise_for_status()
42
+ total_size = int(response.headers.get("content-length", 0))
43
+ with (
44
+ to.open("wb") as f,
45
+ tqdm(
46
+ # Make the URL less verbose in the progress bar
47
+ desc=url.replace("https://s3.amazonaws.com/session-manager-downloads/plugin", "..."),
48
+ total=total_size,
49
+ unit="iB",
50
+ unit_scale=True,
51
+ unit_divisor=1_024,
52
+ ) as pbar,
53
+ ):
54
+ for chunk in response.iter_content(chunk_size=8_192):
55
+ size = f.write(chunk)
56
+ pbar.update(size)
57
+
58
+ return to.absolute()
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def command_as_root(command: list[str], *, root: bool | None = None) -> list[str]:
8
+ """Modify a command to run as root (`sudo`) if not already running as root."""
9
+ root = root or is_root()
10
+ if not root:
11
+ command = ["sudo", *command]
12
+
13
+ return command
14
+
15
+
16
+ def is_root() -> bool:
17
+ """Check if the current user is root."""
18
+ return os.geteuid() == 0
19
+
20
+
21
+ def os_release() -> dict[str, str]:
22
+ """Parse `/etc/os-release` file into a dictionary."""
23
+ content = Path("/etc/os-release").read_text()
24
+ return {
25
+ key.strip('"'): value.strip('"')
26
+ for key, value in (line.split("=", 1) for line in content.splitlines() if "=" in line)
27
+ }
@@ -1,20 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-annoying
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Utils to handle some annoying AWS tasks.
5
+ Project-URL: Homepage, https://github.com/lasuillard/aws-annoying
6
+ Project-URL: Repository, https://github.com/lasuillard/aws-annoying.git
7
+ Project-URL: Issues, https://github.com/lasuillard/aws-annoying/issues
5
8
  Author-email: Yuchan Lee <lasuillard@gmail.com>
6
9
  License-Expression: MIT
7
10
  License-File: LICENSE
8
11
  Requires-Python: <4.0,>=3.9
9
12
  Requires-Dist: boto3>=1.37.1
10
13
  Requires-Dist: pydantic>=2.10.6
14
+ Requires-Dist: requests>=2.32.3
15
+ Requires-Dist: tqdm>=4.67.1
11
16
  Requires-Dist: typer>=0.15.1
12
17
  Provides-Extra: dev
13
- Requires-Dist: boto3-stubs[ecs,secretsmanager,ssm,sts]>=1.37.1; extra == 'dev'
18
+ Requires-Dist: boto3-stubs[ec2,ecs,secretsmanager,ssm,sts]>=1.37.1; extra == 'dev'
14
19
  Requires-Dist: mypy~=1.15.0; extra == 'dev'
15
- Requires-Dist: ruff~=0.9.9; extra == 'dev'
20
+ Requires-Dist: ruff<0.12.0,>=0.9.9; extra == 'dev'
21
+ Requires-Dist: types-requests>=2.31.0.6; extra == 'dev'
16
22
  Provides-Extra: test
17
- Requires-Dist: coverage~=7.6.0; extra == 'test'
23
+ Requires-Dist: coverage<7.9,>=7.6; extra == 'test'
18
24
  Requires-Dist: moto[ecs,secretsmanager,server,ssm]~=5.1.1; extra == 'test'
19
25
  Requires-Dist: pytest-cov~=6.0.0; extra == 'test'
20
26
  Requires-Dist: pytest-env~=1.1.1; extra == 'test'
@@ -30,6 +36,6 @@ Description-Content-Type: text/markdown
30
36
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
37
  [![CI](https://github.com/lasuillard/aws-annoying/actions/workflows/ci.yaml/badge.svg)](https://github.com/lasuillard/aws-annoying/actions/workflows/ci.yaml)
32
38
  [![codecov](https://codecov.io/gh/lasuillard/aws-annoying/graph/badge.svg?token=gbcHMVVz2k)](https://codecov.io/gh/lasuillard/aws-annoying)
33
- ![GitHub Release](https://img.shields.io/github/v/release/lasuillard/aws-annoying)
39
+ ![PyPI - Version](https://img.shields.io/pypi/v/aws-annoying)
34
40
 
35
41
  Utils to handle some annoying AWS tasks.
@@ -0,0 +1,26 @@
1
+ aws_annoying/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ aws_annoying/app.py,sha256=sp50uVoAl4D6Wk3DFpzKZzSsxmSxNYejFxm62b_Kxps,201
3
+ aws_annoying/ecs_task_definition_lifecycle.py,sha256=O36Bf5LBnVJNyYmdlUxhtsIHNoxky1t5YacAXiL9UEI,2803
4
+ aws_annoying/load_variables.py,sha256=380xT1i85HWybgOIWn72xGCDgqYJ2OSa9VOKMlyHg8M,9488
5
+ aws_annoying/main.py,sha256=lWvPCEnm_N9Sx9LA_YDoGHmbCyYWDhkg-UHyt8i-GLY,487
6
+ aws_annoying/mfa/__init__.py,sha256=rbEGhw5lOQZV_XAc3nSbo56JVhsSPpeOgEtiAy9qzEA,50
7
+ aws_annoying/mfa/_app.py,sha256=hpa1Bfx8lRsuZfujM-RyaYU5llOuwGKgf4FwEydthU0,185
8
+ aws_annoying/mfa/configure.py,sha256=i5e0qZBFYafuv3D59eP_JXOMrWSRW_bZp0xgIpOlaPE,5364
9
+ aws_annoying/session_manager/__init__.py,sha256=FkT6jT6OXduOURN61d-U6hgd-XluQbvuVtKXXiXgSEk,105
10
+ aws_annoying/session_manager/_app.py,sha256=89brd-3hEe8ECpJ9Z-ANcOqvduFxbPVA-gqJ50Zr_kU,217
11
+ aws_annoying/session_manager/_common.py,sha256=UzCBDvxVymiZP7t2B65sNi1ZVL6HA67GAkKwIYIFZPI,780
12
+ aws_annoying/session_manager/errors.py,sha256=YioKlRtZ-GUP0F_ts_ebw7-HYkxe8mTes6HK821Kuiw,353
13
+ aws_annoying/session_manager/install.py,sha256=zX2cu-IBYlf9yl8z9P0CTQz72BP6aAMo-KnolC_PGsU,1443
14
+ aws_annoying/session_manager/port_forward.py,sha256=uMcsafTNAHZh-0E1yWXCliMivWyuWV2K9DdVhBZ9pG8,4327
15
+ aws_annoying/session_manager/session_manager.py,sha256=7tqfFPA4bWF0Me_VbUDcq5Cpl1sw3bevGyT8QWfLTOk,12454
16
+ aws_annoying/session_manager/start.py,sha256=1yaMuy-7IWrrDoPHjgygOP_-g_tajfnpaVPZanxsZxs,215
17
+ aws_annoying/session_manager/stop.py,sha256=ttU6nlbVgBkZDtY-DwUyCstv5TFtat5TljkyuY8QICU,1482
18
+ aws_annoying/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ aws_annoying/utils/debugger.py,sha256=UFllDCGI2gPtwo1XS5vqw0qyR6bYr7XknmBwSxalKIc,754
20
+ aws_annoying/utils/downloader.py,sha256=aB5RzT-LpbFX24-2HXlAkdgVowc4TR9FWT_K8WwZ1BE,1923
21
+ aws_annoying/utils/platform.py,sha256=h3DUWmTMM-_4TfTWNqY0uNqyVsBjAuMm2DEbG-daxe8,742
22
+ aws_annoying-0.3.0.dist-info/METADATA,sha256=H6ciT2fBTifIqg0yYSP5IyfK_CaSzl6f1QI_9ktZjMU,1926
23
+ aws_annoying-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
+ aws_annoying-0.3.0.dist-info/entry_points.txt,sha256=s0l5LK26zEUxLRkBHH9Bu5aH1bOPvYVoTMnUcFlzjm8,62
25
+ aws_annoying-0.3.0.dist-info/licenses/LICENSE,sha256=Q5GkvYijQ2KTQ-QWhv43ilzCno4ZrzrEuATEQZd9rYo,1067
26
+ aws_annoying-0.3.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- aws_annoying/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- aws_annoying/app.py,sha256=sp50uVoAl4D6Wk3DFpzKZzSsxmSxNYejFxm62b_Kxps,201
3
- aws_annoying/ecs_task_definition_lifecycle.py,sha256=hiypU5RD5moo0KYHn8YRAyGDSMKh-zPnfxfFYWm6w78,1744
4
- aws_annoying/load_variables.py,sha256=380xT1i85HWybgOIWn72xGCDgqYJ2OSa9VOKMlyHg8M,9488
5
- aws_annoying/main.py,sha256=jngo15w50Jf6nr63N-yV6AEYsFKYzObJc0wI364zS0s,451
6
- aws_annoying/mfa/__init__.py,sha256=rbEGhw5lOQZV_XAc3nSbo56JVhsSPpeOgEtiAy9qzEA,50
7
- aws_annoying/mfa/_app.py,sha256=hpa1Bfx8lRsuZfujM-RyaYU5llOuwGKgf4FwEydthU0,185
8
- aws_annoying/mfa/configure.py,sha256=i5e0qZBFYafuv3D59eP_JXOMrWSRW_bZp0xgIpOlaPE,5364
9
- aws_annoying/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- aws_annoying/utils/debugger.py,sha256=UFllDCGI2gPtwo1XS5vqw0qyR6bYr7XknmBwSxalKIc,754
11
- aws_annoying-0.2.0.dist-info/METADATA,sha256=0neXI2tmvEBoLQ30VMWfbssan-ZvboVlEpW305veurk,1607
12
- aws_annoying-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
- aws_annoying-0.2.0.dist-info/entry_points.txt,sha256=s0l5LK26zEUxLRkBHH9Bu5aH1bOPvYVoTMnUcFlzjm8,62
14
- aws_annoying-0.2.0.dist-info/licenses/LICENSE,sha256=Q5GkvYijQ2KTQ-QWhv43ilzCno4ZrzrEuATEQZd9rYo,1067
15
- aws_annoying-0.2.0.dist-info/RECORD,,