aws-annoying 0.2.1__py3-none-any.whl → 0.4.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.
Files changed (30) hide show
  1. aws_annoying/cli/__init__.py +0 -0
  2. aws_annoying/{ecs_task_definition_lifecycle.py → cli/ecs_task_definition_lifecycle.py} +27 -0
  3. aws_annoying/cli/load_variables.py +139 -0
  4. aws_annoying/{main.py → cli/main.py} +4 -3
  5. aws_annoying/{mfa → cli/mfa}/_app.py +1 -1
  6. aws_annoying/{mfa → cli/mfa}/configure.py +4 -45
  7. aws_annoying/cli/session_manager/__init__.py +3 -0
  8. aws_annoying/cli/session_manager/_app.py +9 -0
  9. aws_annoying/cli/session_manager/_common.py +24 -0
  10. aws_annoying/cli/session_manager/install.py +39 -0
  11. aws_annoying/cli/session_manager/port_forward.py +126 -0
  12. aws_annoying/cli/session_manager/start.py +9 -0
  13. aws_annoying/cli/session_manager/stop.py +50 -0
  14. aws_annoying/mfa.py +54 -0
  15. aws_annoying/session_manager/__init__.py +4 -0
  16. aws_annoying/session_manager/errors.py +10 -0
  17. aws_annoying/session_manager/session_manager.py +318 -0
  18. aws_annoying/utils/downloader.py +58 -0
  19. aws_annoying/utils/platform.py +27 -0
  20. aws_annoying/variables.py +133 -0
  21. {aws_annoying-0.2.1.dist-info → aws_annoying-0.4.0.dist-info}/METADATA +8 -5
  22. aws_annoying-0.4.0.dist-info/RECORD +30 -0
  23. aws_annoying-0.4.0.dist-info/entry_points.txt +2 -0
  24. aws_annoying/load_variables.py +0 -254
  25. aws_annoying-0.2.1.dist-info/RECORD +0 -15
  26. aws_annoying-0.2.1.dist-info/entry_points.txt +0 -2
  27. /aws_annoying/{app.py → cli/app.py} +0 -0
  28. /aws_annoying/{mfa → cli/mfa}/__init__.py +0 -0
  29. {aws_annoying-0.2.1.dist-info → aws_annoying-0.4.0.dist-info}/WHEEL +0 -0
  30. {aws_annoying-0.2.1.dist-info → aws_annoying-0.4.0.dist-info}/licenses/LICENSE +0 -0
File without changes
@@ -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]
@@ -0,0 +1,139 @@
1
+ # flake8: noqa: B008
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+ from typing import NoReturn, Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from aws_annoying.variables import VariableLoader
13
+
14
+ from .app import app
15
+
16
+
17
+ @app.command(
18
+ context_settings={
19
+ # Allow extra arguments for user provided command
20
+ "allow_extra_args": True,
21
+ "ignore_unknown_options": True,
22
+ },
23
+ )
24
+ def load_variables( # noqa: PLR0913
25
+ *,
26
+ ctx: typer.Context,
27
+ arns: list[str] = typer.Option(
28
+ [],
29
+ metavar="ARN",
30
+ help=(
31
+ "ARNs of the secret or parameter to load."
32
+ " The variables are loaded in the order of the ARNs,"
33
+ " overwriting the variables with the same name in the order of the ARNs."
34
+ ),
35
+ ),
36
+ env_prefix: Optional[str] = typer.Option(
37
+ None,
38
+ help="Prefix of the environment variables to load the ARNs from.",
39
+ show_default=False,
40
+ ),
41
+ overwrite_env: bool = typer.Option(
42
+ False, # noqa: FBT003
43
+ help="Overwrite the existing environment variables with the same name.",
44
+ ),
45
+ quiet: bool = typer.Option(
46
+ False, # noqa: FBT003
47
+ help="Suppress all outputs from this command.",
48
+ ),
49
+ dry_run: bool = typer.Option(
50
+ False, # noqa: FBT003
51
+ help="Print the progress only. Neither load variables nor run the command.",
52
+ ),
53
+ replace: bool = typer.Option(
54
+ True, # noqa: FBT003
55
+ help=(
56
+ "Replace the current process (`os.execvpe`) with the command."
57
+ " If disabled, run the command as a `subprocess`."
58
+ ),
59
+ ),
60
+ ) -> NoReturn:
61
+ """Wrapper command to run command with variables from AWS resources injected as environment variables.
62
+
63
+ This script is intended to be used in the ECS environment, where currently AWS does not support
64
+ injecting whole JSON dictionary of secrets or parameters as environment variables directly.
65
+
66
+ It first loads the variables from the AWS sources then runs the command with the variables injected as environment variables.
67
+
68
+ In addition to `--arns` option, you can provide ARNs as the environment variables by providing `--env-prefix`.
69
+ For example, if you have the following environment variables:
70
+
71
+ ```shell
72
+ export LOAD_AWS_CONFIG__001_app_config=arn:aws:secretsmanager:...
73
+ export LOAD_AWS_CONFIG__002_db_config=arn:aws:ssm:...
74
+ ```
75
+
76
+ You can run the following command:
77
+
78
+ ```shell
79
+ aws-annoying load-variables --env-prefix LOAD_AWS_CONFIG__ -- ...
80
+ ```
81
+
82
+ The variables are loaded in the order of option provided, overwriting the variables with the same name in the order of the ARNs.
83
+ Existing environment variables are preserved by default, unless `--overwrite-env` is provided.
84
+ """ # noqa: E501
85
+ console = Console(quiet=quiet, emoji=False)
86
+
87
+ command = ctx.args
88
+ if not command:
89
+ console.print("⚠️ No command provided. Exiting...")
90
+ raise typer.Exit(0)
91
+
92
+ # Mapping of the ARNs by index (index used for ordering)
93
+ map_arns_by_index = {str(idx): arn for idx, arn in enumerate(arns)}
94
+ if env_prefix:
95
+ console.print(f"🔍 Loading ARNs from environment variables with prefix: {env_prefix!r}")
96
+ arns_env = {
97
+ key.removeprefix(env_prefix): value for key, value in os.environ.items() if key.startswith(env_prefix)
98
+ }
99
+ console.print(f"🔍 Found {len(arns_env)} sources from environment variables.")
100
+ map_arns_by_index = arns_env | map_arns_by_index
101
+
102
+ # Briefly show the ARNs
103
+ table = Table("Index", "ARN")
104
+ for idx, arn in sorted(map_arns_by_index.items()):
105
+ table.add_row(idx, arn)
106
+
107
+ console.print(table)
108
+
109
+ # Retrieve the variables
110
+ loader = VariableLoader(dry_run=dry_run)
111
+ console.print("🔍 Retrieving variables from AWS resources...")
112
+ if dry_run:
113
+ console.print("⚠️ Dry run mode enabled. Variables won't be loaded from AWS.")
114
+
115
+ try:
116
+ variables, load_stats = loader.load(map_arns_by_index)
117
+ except Exception as exc: # noqa: BLE001
118
+ console.print(f"❌ Failed to load the variables: {exc!s}")
119
+ raise typer.Exit(1) from None
120
+
121
+ console.print(f"✅ Retrieved {load_stats['secrets']} secrets and {load_stats['parameters']} parameters.")
122
+
123
+ # Prepare the environment variables
124
+ env = os.environ.copy()
125
+ if overwrite_env:
126
+ env.update(variables)
127
+ else:
128
+ # Update variables, preserving the existing ones
129
+ for key, value in variables.items():
130
+ env.setdefault(key, str(value))
131
+
132
+ # Run the command with the variables injected as environment variables, replacing current process
133
+ console.print(f"🚀 Running the command: [bold orchid]{' '.join(command)}[/bold orchid]")
134
+ if replace: # pragma: no cover (not coverable)
135
+ os.execvpe(command[0], command, env=env) # noqa: S606
136
+ # The above line should never return
137
+
138
+ result = subprocess.run(command, env=env, check=False) # noqa: S603
139
+ raise typer.Exit(result.returncode)
@@ -1,9 +1,10 @@
1
1
  # flake8: noqa: F401
2
2
  from __future__ import annotations
3
3
 
4
- import aws_annoying.ecs_task_definition_lifecycle
5
- import aws_annoying.load_variables
6
- import aws_annoying.mfa
4
+ import aws_annoying.cli.ecs_task_definition_lifecycle
5
+ import aws_annoying.cli.load_variables
6
+ import aws_annoying.cli.mfa
7
+ import aws_annoying.cli.session_manager
7
8
  from aws_annoying.utils.debugger import input_as_args
8
9
 
9
10
  # App with all commands registered
@@ -1,6 +1,6 @@
1
1
  import typer
2
2
 
3
- from aws_annoying.app import app
3
+ from aws_annoying.cli.app import app
4
4
 
5
5
  mfa_app = typer.Typer(
6
6
  no_args_is_help=True,
@@ -1,15 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- import configparser
4
3
  from pathlib import Path # noqa: TC003
5
4
  from typing import Optional
6
5
 
7
6
  import boto3
8
7
  import typer
9
- from pydantic import BaseModel, ConfigDict
10
8
  from rich import print # noqa: A004
11
9
  from rich.prompt import Prompt
12
10
 
11
+ from aws_annoying.mfa import MfaConfig, update_credentials
12
+
13
13
  from ._app import mfa_app
14
14
 
15
15
  _CONFIG_INI_SECTION = "aws-annoying:mfa"
@@ -55,7 +55,7 @@ def configure( # noqa: PLR0913
55
55
  aws_config = aws_config.expanduser()
56
56
 
57
57
  # Load configuration
58
- mfa_config, exists = _MfaConfig.from_ini_file(aws_config, _CONFIG_INI_SECTION)
58
+ mfa_config, exists = MfaConfig.from_ini_file(aws_config, _CONFIG_INI_SECTION)
59
59
  if exists:
60
60
  print(f"⚙️ Loaded MFA configuration from AWS config ({aws_config}).")
61
61
 
@@ -94,7 +94,7 @@ def configure( # noqa: PLR0913
94
94
 
95
95
  # Update MFA profile in AWS credentials
96
96
  print(f"✅ Updating MFA profile ([bold]{mfa_profile}[/bold]) to AWS credentials ({aws_credentials})")
97
- _update_credentials(
97
+ update_credentials(
98
98
  aws_credentials,
99
99
  mfa_profile, # type: ignore[arg-type]
100
100
  access_key=credentials["AccessKeyId"],
@@ -114,44 +114,3 @@ def configure( # noqa: PLR0913
114
114
  mfa_config.save_ini_file(aws_config, _CONFIG_INI_SECTION)
115
115
  else:
116
116
  print("⚠️ MFA configuration not persisted.")
117
-
118
-
119
- class _MfaConfig(BaseModel):
120
- model_config = ConfigDict(extra="ignore")
121
-
122
- mfa_profile: Optional[str] = None
123
- mfa_source_profile: Optional[str] = None
124
- mfa_serial_number: Optional[str] = None
125
-
126
- def save_ini_file(self, path: Path, section_key: str) -> None:
127
- """Save configuration to an AWS config file."""
128
- config_ini = configparser.ConfigParser()
129
- config_ini.read(path)
130
- config_ini.setdefault(section_key, {})
131
- for k, v in self.model_dump(exclude_none=True).items():
132
- config_ini[section_key][k] = v
133
-
134
- with path.open("w") as f:
135
- config_ini.write(f)
136
-
137
- @classmethod
138
- def from_ini_file(cls, path: Path, section_key: str) -> tuple[_MfaConfig, bool]:
139
- """Load configuration from an AWS config file, with boolean indicating if the config already exists."""
140
- config_ini = configparser.ConfigParser()
141
- config_ini.read(path)
142
- if config_ini.has_section(section_key):
143
- section = dict(config_ini.items(section_key))
144
- return cls.model_validate(section), True
145
-
146
- return cls(), False
147
-
148
-
149
- def _update_credentials(path: Path, profile: str, *, access_key: str, secret_key: str, session_token: str) -> None:
150
- credentials_ini = configparser.ConfigParser()
151
- credentials_ini.read(path)
152
- credentials_ini.setdefault(profile, {})
153
- credentials_ini[profile]["aws_access_key_id"] = access_key
154
- credentials_ini[profile]["aws_secret_access_key"] = secret_key
155
- credentials_ini[profile]["aws_session_token"] = session_token
156
- with path.open("w") as f:
157
- credentials_ini.write(f)
@@ -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.cli.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 aws_annoying.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,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,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.")
aws_annoying/mfa.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import configparser
4
+ from pathlib import Path # noqa: TC003
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ # TODO(lasuillard): Need some refactoring (configurator class)
10
+ # TODO(lasuillard): Put some logging
11
+
12
+
13
+ class MfaConfig(BaseModel):
14
+ """MFA configuration for AWS profiles."""
15
+
16
+ model_config = ConfigDict(extra="ignore")
17
+
18
+ mfa_profile: Optional[str] = None
19
+ mfa_source_profile: Optional[str] = None
20
+ mfa_serial_number: Optional[str] = None
21
+
22
+ def save_ini_file(self, path: Path, section_key: str) -> None:
23
+ """Save configuration to an AWS config file."""
24
+ config_ini = configparser.ConfigParser()
25
+ config_ini.read(path)
26
+ config_ini.setdefault(section_key, {})
27
+ for k, v in self.model_dump(exclude_none=True).items():
28
+ config_ini[section_key][k] = v
29
+
30
+ with path.open("w") as f:
31
+ config_ini.write(f)
32
+
33
+ @classmethod
34
+ def from_ini_file(cls, path: Path, section_key: str) -> tuple[MfaConfig, bool]:
35
+ """Load configuration from an AWS config file, with boolean indicating if the config already exists."""
36
+ config_ini = configparser.ConfigParser()
37
+ config_ini.read(path)
38
+ if config_ini.has_section(section_key):
39
+ section = dict(config_ini.items(section_key))
40
+ return cls.model_validate(section), True
41
+
42
+ return cls(), False
43
+
44
+
45
+ def update_credentials(path: Path, profile: str, *, access_key: str, secret_key: str, session_token: str) -> None:
46
+ """Update AWS credentials file with the provided profile and credentials."""
47
+ credentials_ini = configparser.ConfigParser()
48
+ credentials_ini.read(path)
49
+ credentials_ini.setdefault(profile, {})
50
+ credentials_ini[profile]["aws_access_key_id"] = access_key
51
+ credentials_ini[profile]["aws_secret_access_key"] = secret_key
52
+ credentials_ini[profile]["aws_session_token"] = session_token
53
+ with path.open("w") as f:
54
+ credentials_ini.write(f)
@@ -0,0 +1,4 @@
1
+ from .errors import PluginNotInstalledError, SessionManagerError, UnsupportedPlatformError
2
+ from .session_manager import SessionManager
3
+
4
+ __all__ = ("PluginNotInstalledError", "SessionManager", "SessionManagerError", "UnsupportedPlatformError")
@@ -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."""