aws-annoying 0.3.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.
- aws_annoying/cli/__init__.py +0 -0
- aws_annoying/cli/load_variables.py +139 -0
- aws_annoying/{main.py → cli/main.py} +4 -4
- aws_annoying/{mfa → cli/mfa}/_app.py +1 -1
- aws_annoying/{mfa → cli/mfa}/configure.py +4 -45
- aws_annoying/cli/session_manager/__init__.py +3 -0
- aws_annoying/{session_manager → cli/session_manager}/_app.py +1 -1
- aws_annoying/cli/session_manager/_common.py +54 -0
- aws_annoying/{session_manager → cli/session_manager}/install.py +2 -2
- aws_annoying/{session_manager → cli/session_manager}/port_forward.py +26 -33
- aws_annoying/cli/session_manager/start.py +47 -0
- aws_annoying/mfa.py +54 -0
- aws_annoying/session_manager/__init__.py +10 -2
- aws_annoying/session_manager/session_manager.py +24 -35
- aws_annoying/session_manager/shortcuts.py +72 -0
- aws_annoying/variables.py +133 -0
- {aws_annoying-0.3.0.dist-info → aws_annoying-0.5.0.dist-info}/METADATA +6 -6
- aws_annoying-0.5.0.dist-info/RECORD +31 -0
- aws_annoying-0.5.0.dist-info/entry_points.txt +2 -0
- aws_annoying/load_variables.py +0 -254
- aws_annoying/session_manager/_common.py +0 -24
- aws_annoying/session_manager/start.py +0 -9
- aws_annoying-0.3.0.dist-info/RECORD +0 -26
- aws_annoying-0.3.0.dist-info/entry_points.txt +0 -2
- /aws_annoying/{app.py → cli/app.py} +0 -0
- /aws_annoying/{ecs_task_definition_lifecycle.py → cli/ecs_task_definition_lifecycle.py} +0 -0
- /aws_annoying/{mfa → cli/mfa}/__init__.py +0 -0
- /aws_annoying/{session_manager → cli/session_manager}/stop.py +0 -0
- {aws_annoying-0.3.0.dist-info → aws_annoying-0.5.0.dist-info}/WHEEL +0 -0
- {aws_annoying-0.3.0.dist-info → aws_annoying-0.5.0.dist-info}/licenses/LICENSE +0 -0
|
File without changes
|
|
@@ -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,10 +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
|
|
7
|
-
import aws_annoying.session_manager
|
|
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
|
|
8
8
|
from aws_annoying.utils.debugger import input_as_args
|
|
9
9
|
|
|
10
10
|
# App with all commands registered
|
|
@@ -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 =
|
|
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
|
-
|
|
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,54 @@
|
|
|
1
|
+
# TODO(lasuillard): Using this file until split CLI from library codebase
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import typer
|
|
9
|
+
from rich.prompt import Confirm
|
|
10
|
+
|
|
11
|
+
from aws_annoying.session_manager import SessionManager as _SessionManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Custom session manager with console interactivity
|
|
15
|
+
class SessionManager(_SessionManager):
|
|
16
|
+
def before_install(self, command: list[str]) -> None:
|
|
17
|
+
if self._confirm:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
confirm = Confirm.ask(f"⚠️ Will run the following command: [bold red]{' '.join(command)}[/bold red]. Proceed?")
|
|
21
|
+
if not confirm:
|
|
22
|
+
raise typer.Abort
|
|
23
|
+
|
|
24
|
+
def install(self, *args: Any, confirm: bool = False, **kwargs: Any) -> None:
|
|
25
|
+
self._confirm = confirm
|
|
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(
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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"])
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich import print # noqa: A004
|
|
7
|
+
|
|
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
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@session_manager_app.command()
|
|
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:
|
|
27
|
+
"""Start new session."""
|
|
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
|
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)
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
from . import
|
|
1
|
+
from .errors import PluginNotInstalledError, SessionManagerError, UnsupportedPlatformError
|
|
2
|
+
from .session_manager import SessionManager
|
|
3
|
+
from .shortcuts import port_forward
|
|
2
4
|
|
|
3
|
-
__all__ = (
|
|
5
|
+
__all__ = (
|
|
6
|
+
"PluginNotInstalledError",
|
|
7
|
+
"SessionManager",
|
|
8
|
+
"SessionManagerError",
|
|
9
|
+
"UnsupportedPlatformError",
|
|
10
|
+
"port_forward",
|
|
11
|
+
)
|