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.
- aws_annoying/cli/__init__.py +0 -0
- aws_annoying/{ecs_task_definition_lifecycle.py → cli/ecs_task_definition_lifecycle.py} +27 -0
- aws_annoying/cli/load_variables.py +139 -0
- aws_annoying/{main.py → cli/main.py} +4 -3
- 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/cli/session_manager/_app.py +9 -0
- aws_annoying/cli/session_manager/_common.py +24 -0
- aws_annoying/cli/session_manager/install.py +39 -0
- aws_annoying/cli/session_manager/port_forward.py +126 -0
- aws_annoying/cli/session_manager/start.py +9 -0
- aws_annoying/cli/session_manager/stop.py +50 -0
- aws_annoying/mfa.py +54 -0
- aws_annoying/session_manager/__init__.py +4 -0
- aws_annoying/session_manager/errors.py +10 -0
- aws_annoying/session_manager/session_manager.py +318 -0
- aws_annoying/utils/downloader.py +58 -0
- aws_annoying/utils/platform.py +27 -0
- aws_annoying/variables.py +133 -0
- {aws_annoying-0.2.1.dist-info → aws_annoying-0.4.0.dist-info}/METADATA +8 -5
- aws_annoying-0.4.0.dist-info/RECORD +30 -0
- aws_annoying-0.4.0.dist-info/entry_points.txt +2 -0
- aws_annoying/load_variables.py +0 -254
- aws_annoying-0.2.1.dist-info/RECORD +0 -15
- aws_annoying-0.2.1.dist-info/entry_points.txt +0 -2
- /aws_annoying/{app.py → cli/app.py} +0 -0
- /aws_annoying/{mfa → cli/mfa}/__init__.py +0 -0
- {aws_annoying-0.2.1.dist-info → aws_annoying-0.4.0.dist-info}/WHEEL +0 -0
- {aws_annoying-0.2.1.dist-info → aws_annoying-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# flake8: noqa: B008
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, TypedDict
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
|
|
9
|
+
# Type aliases for readability
|
|
10
|
+
_ARN = str
|
|
11
|
+
_Variables = dict[str, Any]
|
|
12
|
+
|
|
13
|
+
# TODO(lasuillard): Need some refactoring (with #2, #3)
|
|
14
|
+
# TODO(lasuillard): Put some logging
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _LoadStatsDict(TypedDict):
|
|
18
|
+
secrets: int
|
|
19
|
+
parameters: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VariableLoader: # noqa: D101
|
|
23
|
+
def __init__(self, *, dry_run: bool) -> None:
|
|
24
|
+
"""Initialize the VariableLoader.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
dry_run: Whether to run in dry-run mode.
|
|
28
|
+
console: Rich console instance.
|
|
29
|
+
"""
|
|
30
|
+
self.dry_run = dry_run
|
|
31
|
+
|
|
32
|
+
# TODO(lasuillard): Currently not using pagination (do we need more than 10-20 secrets or parameters each?)
|
|
33
|
+
# ; consider adding it if needed
|
|
34
|
+
def load(self, map_arns: dict[str, _ARN]) -> tuple[dict[str, Any], _LoadStatsDict]:
|
|
35
|
+
"""Load the variables from the AWS Secrets Manager and SSM Parameter Store.
|
|
36
|
+
|
|
37
|
+
Each secret or parameter should be a valid dictionary, where the keys are the variable names
|
|
38
|
+
and the values are the variable values.
|
|
39
|
+
|
|
40
|
+
The items are merged in the order of the key of provided mapping, overwriting the variables with the same name
|
|
41
|
+
in the order of the keys.
|
|
42
|
+
"""
|
|
43
|
+
# Split the ARNs by resource types
|
|
44
|
+
secrets_map, parameters_map = {}, {}
|
|
45
|
+
for idx, arn in map_arns.items():
|
|
46
|
+
if arn.startswith("arn:aws:secretsmanager:"):
|
|
47
|
+
secrets_map[idx] = arn
|
|
48
|
+
elif arn.startswith("arn:aws:ssm:"):
|
|
49
|
+
parameters_map[idx] = arn
|
|
50
|
+
else:
|
|
51
|
+
msg = f"Unsupported resource: {arn!r}"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
|
|
54
|
+
# Retrieve variables from AWS resources
|
|
55
|
+
secrets: dict[str, _Variables]
|
|
56
|
+
parameters: dict[str, _Variables]
|
|
57
|
+
if self.dry_run:
|
|
58
|
+
secrets = {idx: {} for idx, _ in secrets_map.items()}
|
|
59
|
+
parameters = {idx: {} for idx, _ in parameters_map.items()}
|
|
60
|
+
else:
|
|
61
|
+
secrets = self._retrieve_secrets(secrets_map)
|
|
62
|
+
parameters = self._retrieve_parameters(parameters_map)
|
|
63
|
+
|
|
64
|
+
load_stats: _LoadStatsDict = {
|
|
65
|
+
"secrets": len(secrets),
|
|
66
|
+
"parameters": len(parameters),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Merge the variables in order
|
|
70
|
+
full_variables = secrets | parameters # Keys MUST NOT conflict
|
|
71
|
+
merged_in_order = {}
|
|
72
|
+
for _, variables in sorted(full_variables.items()):
|
|
73
|
+
merged_in_order.update(variables)
|
|
74
|
+
|
|
75
|
+
return merged_in_order, load_stats
|
|
76
|
+
|
|
77
|
+
def _retrieve_secrets(self, secrets_map: dict[str, _ARN]) -> dict[str, _Variables]:
|
|
78
|
+
"""Retrieve the secrets from AWS Secrets Manager."""
|
|
79
|
+
if not secrets_map:
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
secretsmanager = boto3.client("secretsmanager")
|
|
83
|
+
|
|
84
|
+
# Retrieve the secrets
|
|
85
|
+
arns = list(secrets_map.values())
|
|
86
|
+
response = secretsmanager.batch_get_secret_value(SecretIdList=arns)
|
|
87
|
+
if errors := response["Errors"]:
|
|
88
|
+
msg = f"Failed to retrieve secrets: {errors!r}"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
|
|
91
|
+
# Parse the secrets
|
|
92
|
+
secrets = response["SecretValues"]
|
|
93
|
+
result = {}
|
|
94
|
+
for secret in secrets:
|
|
95
|
+
arn = secret["ARN"]
|
|
96
|
+
order_key = next(key for key, value in secrets_map.items() if value == arn)
|
|
97
|
+
data = json.loads(secret["SecretString"])
|
|
98
|
+
if not isinstance(data, dict):
|
|
99
|
+
msg = f"Secret data must be a valid dictionary, but got: {type(data)!r}"
|
|
100
|
+
raise TypeError(msg)
|
|
101
|
+
|
|
102
|
+
result[order_key] = data
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
def _retrieve_parameters(self, parameters_map: dict[str, _ARN]) -> dict[str, _Variables]:
|
|
107
|
+
"""Retrieve the parameters from AWS SSM Parameter Store."""
|
|
108
|
+
if not parameters_map:
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
ssm = boto3.client("ssm")
|
|
112
|
+
|
|
113
|
+
# Retrieve the parameters
|
|
114
|
+
parameter_names = list(parameters_map.values())
|
|
115
|
+
response = ssm.get_parameters(Names=parameter_names, WithDecryption=True)
|
|
116
|
+
if errors := response["InvalidParameters"]:
|
|
117
|
+
msg = f"Failed to retrieve parameters: {errors!r}"
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
|
|
120
|
+
# Parse the parameters
|
|
121
|
+
parameters = response["Parameters"]
|
|
122
|
+
result = {}
|
|
123
|
+
for parameter in parameters:
|
|
124
|
+
arn = parameter["ARN"]
|
|
125
|
+
order_key = next(key for key, value in parameters_map.items() if value == arn)
|
|
126
|
+
data = json.loads(parameter["Value"])
|
|
127
|
+
if not isinstance(data, dict):
|
|
128
|
+
msg = f"Parameter data must be a valid dictionary, but got: {type(data)!r}"
|
|
129
|
+
raise TypeError(msg)
|
|
130
|
+
|
|
131
|
+
result[order_key] = data
|
|
132
|
+
|
|
133
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-annoying
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -9,13 +9,16 @@ Author-email: Yuchan Lee <lasuillard@gmail.com>
|
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Python: <4.0,>=3.9
|
|
12
|
-
Requires-Dist: boto3
|
|
13
|
-
Requires-Dist: pydantic
|
|
14
|
-
Requires-Dist:
|
|
12
|
+
Requires-Dist: boto3<2,>=1
|
|
13
|
+
Requires-Dist: pydantic<3,>=2
|
|
14
|
+
Requires-Dist: requests<3,>=2
|
|
15
|
+
Requires-Dist: tqdm<5,>=4
|
|
16
|
+
Requires-Dist: typer<1,>=0
|
|
15
17
|
Provides-Extra: dev
|
|
16
|
-
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'
|
|
17
19
|
Requires-Dist: mypy~=1.15.0; extra == 'dev'
|
|
18
20
|
Requires-Dist: ruff<0.12.0,>=0.9.9; extra == 'dev'
|
|
21
|
+
Requires-Dist: types-requests>=2.31.0.6; extra == 'dev'
|
|
19
22
|
Provides-Extra: test
|
|
20
23
|
Requires-Dist: coverage<7.9,>=7.6; extra == 'test'
|
|
21
24
|
Requires-Dist: moto[ecs,secretsmanager,server,ssm]~=5.1.1; extra == 'test'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
aws_annoying/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
aws_annoying/mfa.py,sha256=m6-V1bWeUWsAmRddl-lv13mPCMnftoPzJoNnZ0kiaWQ,2007
|
|
3
|
+
aws_annoying/variables.py,sha256=a9cMS9JU-XA2h1tztO7ofixoDEpqtS_eVEiWrQ75mTo,4761
|
|
4
|
+
aws_annoying/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
aws_annoying/cli/app.py,sha256=sp50uVoAl4D6Wk3DFpzKZzSsxmSxNYejFxm62b_Kxps,201
|
|
6
|
+
aws_annoying/cli/ecs_task_definition_lifecycle.py,sha256=O36Bf5LBnVJNyYmdlUxhtsIHNoxky1t5YacAXiL9UEI,2803
|
|
7
|
+
aws_annoying/cli/load_variables.py,sha256=eWNByUEc1ijF8uCe_egdAnjWxfMNCZeVr0vtTtQLe3Y,5086
|
|
8
|
+
aws_annoying/cli/main.py,sha256=TSzPeMkgIgKFf3bom_vDkFYK0bHF1r5K9ADreZUV3k4,503
|
|
9
|
+
aws_annoying/cli/mfa/__init__.py,sha256=rbEGhw5lOQZV_XAc3nSbo56JVhsSPpeOgEtiAy9qzEA,50
|
|
10
|
+
aws_annoying/cli/mfa/_app.py,sha256=Ub7gxb6kGF3Ve1ucQSOjHmc4jAu8mxgegcXsIbOzLLQ,189
|
|
11
|
+
aws_annoying/cli/mfa/configure.py,sha256=vsoHfTVFF2dPgiYsp2L-EkMwtAA0_-tVwFd6Wv6DscU,3746
|
|
12
|
+
aws_annoying/cli/session_manager/__init__.py,sha256=FkT6jT6OXduOURN61d-U6hgd-XluQbvuVtKXXiXgSEk,105
|
|
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
|
|
18
|
+
aws_annoying/cli/session_manager/stop.py,sha256=ttU6nlbVgBkZDtY-DwUyCstv5TFtat5TljkyuY8QICU,1482
|
|
19
|
+
aws_annoying/session_manager/__init__.py,sha256=swbRdFh9CdO6tzyBjSxN0KC7MMASvptIpbsijUPIZgI,243
|
|
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
|
|
22
|
+
aws_annoying/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
aws_annoying/utils/debugger.py,sha256=UFllDCGI2gPtwo1XS5vqw0qyR6bYr7XknmBwSxalKIc,754
|
|
24
|
+
aws_annoying/utils/downloader.py,sha256=aB5RzT-LpbFX24-2HXlAkdgVowc4TR9FWT_K8WwZ1BE,1923
|
|
25
|
+
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,,
|