aws-annoying 0.3.0__tar.gz → 0.4.0__tar.gz

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 (100) hide show
  1. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.github/workflows/ci.yaml +1 -8
  2. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.pre-commit-config.yaml +1 -3
  3. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/PKG-INFO +6 -6
  4. aws_annoying-0.4.0/aws_annoying/cli/load_variables.py +139 -0
  5. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/main.py +4 -4
  6. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/mfa/_app.py +1 -1
  7. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/mfa/configure.py +4 -45
  8. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/session_manager/_app.py +1 -1
  9. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/session_manager/_common.py +1 -1
  10. aws_annoying-0.4.0/aws_annoying/mfa.py +54 -0
  11. aws_annoying-0.4.0/aws_annoying/session_manager/__init__.py +4 -0
  12. aws_annoying-0.4.0/aws_annoying/variables.py +133 -0
  13. aws_annoying-0.4.0/examples/dbeaver/README.md +49 -0
  14. aws_annoying-0.4.0/examples/dbeaver/after-disconnect.png +0 -0
  15. aws_annoying-0.4.0/examples/dbeaver/before-connect.png +0 -0
  16. aws_annoying-0.4.0/examples/dbeaver/new-connection.png +0 -0
  17. aws_annoying-0.4.0/examples/dbeaver/test-connection.png +0 -0
  18. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/pyproject.toml +7 -7
  19. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/_helpers/__init__.py +2 -1
  20. aws_annoying-0.4.0/tests/cli/_helpers/invoke.py +14 -0
  21. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/mfa/test_configure.py +5 -4
  22. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_basic/stdout.txt +1 -1
  23. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_dry_run/stdout.txt +1 -1
  24. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_env_prefix/stdout.txt +1 -1
  25. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_overwrite_env/stdout.txt +1 -1
  26. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/test_ecs_task_definition_lifecycle.py +1 -1
  27. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/test_load_variables.py +8 -10
  28. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/tests/conftest.py +1 -25
  29. aws_annoying-0.4.0/tests/session_manager/test_errors.py +0 -0
  30. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/tests/session_manager/test_session_manager.py +2 -2
  31. aws_annoying-0.4.0/tests/test_mfa.py +0 -0
  32. aws_annoying-0.4.0/tests/test_variables.py +0 -0
  33. aws_annoying-0.4.0/tests/utils/__init__.py +0 -0
  34. aws_annoying-0.4.0/tests/utils/test_downloader.py +0 -0
  35. aws_annoying-0.4.0/tests/utils/test_platform.py +0 -0
  36. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/uv.lock +6 -6
  37. aws_annoying-0.3.0/aws_annoying/load_variables.py +0 -254
  38. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.devcontainer/.env.example +0 -0
  39. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.devcontainer/Dockerfile +0 -0
  40. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.devcontainer/devcontainer.json +0 -0
  41. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.devcontainer/docker-compose.devcontainer.yaml +0 -0
  42. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.devcontainer/onCreateCommand.sh +0 -0
  43. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.devcontainer/postAttachCommand.sh +0 -0
  44. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.editorconfig +0 -0
  45. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.gitattributes +0 -0
  46. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.github/dependabot.yaml +0 -0
  47. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.github/workflows/release.yaml +0 -0
  48. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.gitignore +0 -0
  49. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.python-version +0 -0
  50. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.vscode/extensions.json +0 -0
  51. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.vscode/launch.json +0 -0
  52. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/.vscode/settings.json +0 -0
  53. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/LICENSE +0 -0
  54. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/Makefile +0 -0
  55. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/README.md +0 -0
  56. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/aws_annoying/__init__.py +0 -0
  57. {aws_annoying-0.3.0/aws_annoying/utils → aws_annoying-0.4.0/aws_annoying/cli}/__init__.py +0 -0
  58. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/app.py +0 -0
  59. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/ecs_task_definition_lifecycle.py +0 -0
  60. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/mfa/__init__.py +0 -0
  61. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/session_manager/__init__.py +0 -0
  62. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/session_manager/install.py +0 -0
  63. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/session_manager/port_forward.py +0 -0
  64. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/session_manager/start.py +0 -0
  65. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.4.0/aws_annoying/cli}/session_manager/stop.py +0 -0
  66. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/aws_annoying/session_manager/errors.py +0 -0
  67. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/aws_annoying/session_manager/session_manager.py +0 -0
  68. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/aws_annoying/utils}/__init__.py +0 -0
  69. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/aws_annoying/utils/debugger.py +0 -0
  70. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/aws_annoying/utils/downloader.py +0 -0
  71. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/aws_annoying/utils/platform.py +0 -0
  72. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/console/ecs-exec/README.md +0 -0
  73. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/console/ecs-exec/ecs-console.png +0 -0
  74. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/console/ecs-exec/ecs-exec.user.js +0 -0
  75. {aws_annoying-0.3.0 → aws_annoying-0.4.0}/console/ecs-exec/session-manager.png +0 -0
  76. {aws_annoying-0.3.0/tests/mfa → aws_annoying-0.4.0/tests}/__init__.py +0 -0
  77. {aws_annoying-0.3.0/tests/session_manager → aws_annoying-0.4.0/tests/cli}/__init__.py +0 -0
  78. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/_helpers/command_builder.py +0 -0
  79. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/_helpers/scripts/printenv.py +0 -0
  80. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/_helpers/string_.py +0 -0
  81. {aws_annoying-0.3.0/tests/utils → aws_annoying-0.4.0/tests/cli/mfa}/__init__.py +0 -0
  82. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/mfa/snapshots/test_configure/test_basic/persist/aws_config.ini +0 -0
  83. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/mfa/snapshots/test_configure/test_basic/persist/stdout.txt +0 -0
  84. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/mfa/snapshots/test_configure/test_basic/skip_persist/stdout.txt +0 -0
  85. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/mfa/snapshots/test_configure/test_load_existing_config/aws_config.ini +0 -0
  86. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/mfa/snapshots/test_configure/test_load_existing_config/stdout.txt +0 -0
  87. /aws_annoying-0.3.0/tests/session_manager/test_errors.py → /aws_annoying-0.4.0/tests/cli/session_manager/__init__.py +0 -0
  88. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/session_manager/test_install.py +0 -0
  89. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/session_manager/test_port_forward.py +0 -0
  90. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/session_manager/test_start.py +0 -0
  91. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/session_manager/test_stop.py +0 -0
  92. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_ecs_task_definition_lifecycle/test_basic/stdout.txt +0 -0
  93. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_ecs_task_definition_lifecycle/test_dry_run/stdout.txt +0 -0
  94. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_nothing/stdout.txt +0 -0
  95. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_replace_quiet/stdout.txt +0 -0
  96. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_resource_not_found/ssm/stdout.txt +0 -0
  97. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/snapshots/test_load_variables/test_unsupported_resource/stdout.txt +0 -0
  98. {aws_annoying-0.3.0/tests → aws_annoying-0.4.0/tests/cli}/test_app.py +0 -0
  99. /aws_annoying-0.3.0/tests/utils/test_downloader.py → /aws_annoying-0.4.0/tests/cli/test_main.py +0 -0
  100. /aws_annoying-0.3.0/tests/utils/test_platform.py → /aws_annoying-0.4.0/tests/session_manager/__init__.py +0 -0
@@ -44,16 +44,9 @@ jobs:
44
44
  strategy:
45
45
  fail-fast: false
46
46
  matrix:
47
- os: [ubuntu-latest]
47
+ os: [ubuntu-latest, macos-latest, windows-latest]
48
48
  python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
49
49
 
50
- # TODO(lasuillard): Simplified matrix for macOS and Windows for testing
51
- include:
52
- - os: macos-latest
53
- python-version: "3.9"
54
- - os: windows-latest
55
- python-version: "3.9"
56
-
57
50
  steps:
58
51
  - name: Checkout
59
52
  uses: actions/checkout@v4
@@ -19,8 +19,6 @@ repos:
19
19
  - --extend-exclude
20
20
  - utils/debugger.py
21
21
  - --extend-exclude
22
- - main.py
23
- - --extend-exclude
24
22
  - "**/_*.py"
25
23
  - id: test-matching-source
26
24
  args:
@@ -29,7 +27,7 @@ repos:
29
27
  - --extend-exclude
30
28
  - "**/conftest.py"
31
29
  - --extend-exclude
32
- - _*/**/*.py
30
+ - "**/_*/**/*.py"
33
31
  - id: preferred-suffix
34
32
  args: [--rename]
35
33
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-annoying
3
- Version: 0.3.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,11 +9,11 @@ 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>=1.37.1
13
- Requires-Dist: pydantic>=2.10.6
14
- Requires-Dist: requests>=2.32.3
15
- Requires-Dist: tqdm>=4.67.1
16
- Requires-Dist: typer>=0.15.1
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
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: boto3-stubs[ec2,ecs,secretsmanager,ssm,sts]>=1.37.1; extra == 'dev'
19
19
  Requires-Dist: mypy~=1.15.0; extra == 'dev'
@@ -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,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)
@@ -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
  session_manager_app = typer.Typer(
6
6
  no_args_is_help=True,
@@ -6,7 +6,7 @@ from typing import Any
6
6
  import typer
7
7
  from rich.prompt import Confirm
8
8
 
9
- from .session_manager import SessionManager as _SessionManager
9
+ from aws_annoying.session_manager import SessionManager as _SessionManager
10
10
 
11
11
 
12
12
  # Custom session manager with console interactivity
@@ -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,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
@@ -0,0 +1,49 @@
1
+ # DBeaver
2
+
3
+ Example usage of the `session-manager` command to automate [DBeaver](https://dbeaver.io/) connections through Session Manager.
4
+
5
+ 1. Install the **aws-annoying** CLI. Here, we use [pipx](https://github.com/pypa/pipx):
6
+
7
+ ```shell
8
+ pipx install aws-annoying
9
+ ```
10
+
11
+ 2. Run DBeaver. Since DBeaver, by default, runs scripts in a non-login shell, environment variables may need to be forwarded.
12
+
13
+ Below is a macOS-specific example for running DBeaver with user environment variables:
14
+
15
+ ```shell
16
+ export && open -a 'DBeaver'
17
+ ```
18
+
19
+ You can save this command in a `.command` file for convenience. Optionally, specify the AWS profile to use:
20
+
21
+ ```shell
22
+ export && AWS_DEFAULT_PROFILE=mfa open -a 'DBeaver'
23
+ ```
24
+
25
+ 3. Create a new connection:
26
+
27
+ ![New Connection](./new-connection.png)
28
+
29
+ 4. Update the **Before Connect** script:
30
+
31
+ ![Before Connect](./before-connect.png)
32
+
33
+ ```shell
34
+ aws-annoying session-manager port-forward --local-port ${port} --through "<EC2 instance name or ID>" --remote-host "<Database hostname>" --remote-port "<Database port>" --pid-file /tmp/dbeaver-${port}.pid --terminate-running-process --log-file /tmp/dbeaver-${port}.log
35
+ ```
36
+
37
+ Update `--through`, `--remote-host`, and `--remote-port` as needed, based on your infrastructure and database engine. Additionally, the **Pause after execute (ms)** setting may need adjustment based on your network conditions.
38
+
39
+ 5. Update the **After Disconnect** script:
40
+
41
+ ![After Disconnect](./after-disconnect.png)
42
+
43
+ ```shell
44
+ aws-annoying session-manager stop --pid-file /tmp/dbeaver-${port}.pid
45
+ ```
46
+
47
+ 6. Run **Test Connection ...** to verify the setup.
48
+
49
+ ![Test Connection](./test-connection.png)
@@ -1,21 +1,21 @@
1
1
  [project]
2
2
  name = "aws-annoying"
3
3
  description = "Utils to handle some annoying AWS tasks."
4
- version = "0.3.0"
4
+ version = "0.4.0"
5
5
  authors = [{ name = "Yuchan Lee", email = "lasuillard@gmail.com" }]
6
6
  readme = "README.md"
7
7
  license = "MIT"
8
8
  requires-python = ">=3.9, <4.0"
9
9
  dependencies = [
10
- "boto3>=1.37.1",
11
- "requests>=2.32.3",
12
- "tqdm>=4.67.1",
13
- "typer>=0.15.1",
14
- "pydantic>=2.10.6",
10
+ "boto3>=1,<2",
11
+ "requests>=2,<3",
12
+ "tqdm>=4,<5",
13
+ "typer>=0,<1",
14
+ "pydantic>=2,<3",
15
15
  ]
16
16
 
17
17
  [project.scripts]
18
- "aws-annoying" = "aws_annoying.main:entrypoint"
18
+ "aws-annoying" = "aws_annoying.cli.main:entrypoint"
19
19
 
20
20
  [project.optional-dependencies]
21
21
  dev = [
@@ -1,8 +1,9 @@
1
1
  from pathlib import Path
2
2
 
3
3
  from .command_builder import repeat_options
4
+ from .invoke import invoke_cli
4
5
  from .string_ import normalize_console_output
5
6
 
6
- __all__ = ("PRINTENV_PY", "normalize_console_output", "printenv_py", "repeat_options")
7
+ __all__ = ("PRINTENV_PY", "invoke_cli", "normalize_console_output", "printenv_py", "repeat_options")
7
8
 
8
9
  PRINTENV_PY = (Path(__file__).parent / "scripts" / "printenv.py").absolute()
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+
7
+ def invoke_cli(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
8
+ return subprocess.run( # noqa: S603
9
+ ["uv", "run", "aws-annoying", *args], # noqa: S607
10
+ check=False,
11
+ capture_output=True,
12
+ text=True,
13
+ env=(env or os.environ), # * `AWS_ENDPOINT_URL` should be inherited appropriately to use Moto or LocalStack
14
+ )
@@ -7,9 +7,10 @@ from unittest import mock
7
7
  import pytest
8
8
  from typer.testing import CliRunner
9
9
 
10
- from aws_annoying.main import app
11
- from aws_annoying.mfa.configure import _CONFIG_INI_SECTION, _MfaConfig
12
- from tests._helpers import normalize_console_output
10
+ from aws_annoying.cli.main import app
11
+ from aws_annoying.cli.mfa.configure import _CONFIG_INI_SECTION
12
+ from aws_annoying.mfa import MfaConfig
13
+ from tests.cli._helpers import normalize_console_output
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from pathlib import Path
@@ -79,7 +80,7 @@ def test_load_existing_config(snapshot: Snapshot, tmp_path: Path) -> None:
79
80
  mfa_profile = "mfa"
80
81
  aws_credentials = tmp_path / "credentials"
81
82
  aws_config = tmp_path / "config"
82
- _MfaConfig(
83
+ MfaConfig(
83
84
  mfa_profile=mfa_profile,
84
85
  mfa_source_profile="default",
85
86
  mfa_serial_number="1234567890",
@@ -6,7 +6,7 @@
6
6
  └───────┴──────────────────────────────────────────────────────────────────────┘
7
7
  🔍 Retrieving variables from AWS resources...
8
8
  ✅ Retrieved 1 secrets and 1 parameters.
9
- 🚀 Running the command: tests/_helpers/scripts/printenv.py DJANGO_SETTINGS_MODULE DJANGO_SECRET_KEY DJANGO_DEBUG DJANGO_ALLOWED_HOSTS
9
+ 🚀 Running the command: tests/cli/_helpers/scripts/printenv.py DJANGO_SETTINGS_MODULE DJANGO_SECRET_KEY DJANGO_DEBUG DJANGO_ALLOWED_HOSTS
10
10
  DJANGO_SETTINGS_MODULE=config.settings.development
11
11
  DJANGO_SECRET_KEY=my-secret-key
12
12
  DJANGO_DEBUG=False