aws-annoying 0.3.0__tar.gz → 0.5.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 (104) hide show
  1. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.github/workflows/ci.yaml +1 -8
  2. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.pre-commit-config.yaml +1 -3
  3. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/PKG-INFO +6 -6
  4. aws_annoying-0.5.0/aws_annoying/cli/load_variables.py +139 -0
  5. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/main.py +4 -4
  6. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/mfa/_app.py +1 -1
  7. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/mfa/configure.py +4 -45
  8. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/session_manager/_app.py +1 -1
  9. aws_annoying-0.5.0/aws_annoying/cli/session_manager/_common.py +54 -0
  10. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/session_manager/install.py +2 -2
  11. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/session_manager/port_forward.py +26 -33
  12. aws_annoying-0.5.0/aws_annoying/cli/session_manager/start.py +47 -0
  13. aws_annoying-0.5.0/aws_annoying/mfa.py +54 -0
  14. aws_annoying-0.5.0/aws_annoying/session_manager/__init__.py +11 -0
  15. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/aws_annoying/session_manager/session_manager.py +24 -35
  16. aws_annoying-0.5.0/aws_annoying/session_manager/shortcuts.py +72 -0
  17. aws_annoying-0.5.0/aws_annoying/variables.py +133 -0
  18. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/console/ecs-exec/ecs-exec.user.js +3 -3
  19. aws_annoying-0.5.0/examples/dbeaver/README.md +49 -0
  20. aws_annoying-0.5.0/examples/dbeaver/after-disconnect.png +0 -0
  21. aws_annoying-0.5.0/examples/dbeaver/before-connect.png +0 -0
  22. aws_annoying-0.5.0/examples/dbeaver/new-connection.png +0 -0
  23. aws_annoying-0.5.0/examples/dbeaver/test-connection.png +0 -0
  24. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/pyproject.toml +7 -7
  25. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/_helpers/__init__.py +2 -1
  26. aws_annoying-0.5.0/tests/cli/_helpers/invoke.py +14 -0
  27. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/mfa/test_configure.py +5 -4
  28. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_basic/stdout.txt +1 -1
  29. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_dry_run/stdout.txt +1 -1
  30. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_env_prefix/stdout.txt +1 -1
  31. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_overwrite_env/stdout.txt +1 -1
  32. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/test_ecs_task_definition_lifecycle.py +1 -1
  33. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/test_load_variables.py +8 -10
  34. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/tests/conftest.py +1 -25
  35. aws_annoying-0.5.0/tests/session_manager/test_errors.py +0 -0
  36. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/tests/session_manager/test_session_manager.py +4 -5
  37. aws_annoying-0.5.0/tests/session_manager/test_shortcuts.py +0 -0
  38. aws_annoying-0.5.0/tests/test_mfa.py +0 -0
  39. aws_annoying-0.5.0/tests/test_variables.py +0 -0
  40. aws_annoying-0.5.0/tests/utils/__init__.py +0 -0
  41. aws_annoying-0.5.0/tests/utils/test_downloader.py +0 -0
  42. aws_annoying-0.5.0/tests/utils/test_platform.py +0 -0
  43. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/uv.lock +6 -6
  44. aws_annoying-0.3.0/aws_annoying/load_variables.py +0 -254
  45. aws_annoying-0.3.0/aws_annoying/session_manager/_common.py +0 -24
  46. aws_annoying-0.3.0/aws_annoying/session_manager/start.py +0 -9
  47. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.devcontainer/.env.example +0 -0
  48. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.devcontainer/Dockerfile +0 -0
  49. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.devcontainer/devcontainer.json +0 -0
  50. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.devcontainer/docker-compose.devcontainer.yaml +0 -0
  51. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.devcontainer/onCreateCommand.sh +0 -0
  52. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.devcontainer/postAttachCommand.sh +0 -0
  53. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.editorconfig +0 -0
  54. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.gitattributes +0 -0
  55. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.github/dependabot.yaml +0 -0
  56. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.github/workflows/release.yaml +0 -0
  57. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.gitignore +0 -0
  58. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.python-version +0 -0
  59. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.vscode/extensions.json +0 -0
  60. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.vscode/launch.json +0 -0
  61. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/.vscode/settings.json +0 -0
  62. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/LICENSE +0 -0
  63. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/Makefile +0 -0
  64. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/README.md +0 -0
  65. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/aws_annoying/__init__.py +0 -0
  66. {aws_annoying-0.3.0/aws_annoying/utils → aws_annoying-0.5.0/aws_annoying/cli}/__init__.py +0 -0
  67. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/app.py +0 -0
  68. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/ecs_task_definition_lifecycle.py +0 -0
  69. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/mfa/__init__.py +0 -0
  70. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/session_manager/__init__.py +0 -0
  71. {aws_annoying-0.3.0/aws_annoying → aws_annoying-0.5.0/aws_annoying/cli}/session_manager/stop.py +0 -0
  72. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/aws_annoying/session_manager/errors.py +0 -0
  73. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/aws_annoying/utils}/__init__.py +0 -0
  74. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/aws_annoying/utils/debugger.py +0 -0
  75. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/aws_annoying/utils/downloader.py +0 -0
  76. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/aws_annoying/utils/platform.py +0 -0
  77. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/console/ecs-exec/README.md +0 -0
  78. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/console/ecs-exec/ecs-console.png +0 -0
  79. {aws_annoying-0.3.0 → aws_annoying-0.5.0}/console/ecs-exec/session-manager.png +0 -0
  80. {aws_annoying-0.3.0/tests/mfa → aws_annoying-0.5.0/tests}/__init__.py +0 -0
  81. {aws_annoying-0.3.0/tests/session_manager → aws_annoying-0.5.0/tests/cli}/__init__.py +0 -0
  82. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/_helpers/command_builder.py +0 -0
  83. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/_helpers/scripts/printenv.py +0 -0
  84. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/_helpers/string_.py +0 -0
  85. {aws_annoying-0.3.0/tests/utils → aws_annoying-0.5.0/tests/cli/mfa}/__init__.py +0 -0
  86. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/mfa/snapshots/test_configure/test_basic/persist/aws_config.ini +0 -0
  87. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/mfa/snapshots/test_configure/test_basic/persist/stdout.txt +0 -0
  88. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/mfa/snapshots/test_configure/test_basic/skip_persist/stdout.txt +0 -0
  89. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/mfa/snapshots/test_configure/test_load_existing_config/aws_config.ini +0 -0
  90. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/mfa/snapshots/test_configure/test_load_existing_config/stdout.txt +0 -0
  91. /aws_annoying-0.3.0/tests/session_manager/test_errors.py → /aws_annoying-0.5.0/tests/cli/session_manager/__init__.py +0 -0
  92. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/session_manager/test_install.py +0 -0
  93. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/session_manager/test_port_forward.py +0 -0
  94. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/session_manager/test_start.py +0 -0
  95. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/session_manager/test_stop.py +0 -0
  96. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_ecs_task_definition_lifecycle/test_basic/stdout.txt +0 -0
  97. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_ecs_task_definition_lifecycle/test_dry_run/stdout.txt +0 -0
  98. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_nothing/stdout.txt +0 -0
  99. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_replace_quiet/stdout.txt +0 -0
  100. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_resource_not_found/ssm/stdout.txt +0 -0
  101. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/snapshots/test_load_variables/test_unsupported_resource/stdout.txt +0 -0
  102. {aws_annoying-0.3.0/tests → aws_annoying-0.5.0/tests/cli}/test_app.py +0 -0
  103. /aws_annoying-0.3.0/tests/utils/test_downloader.py → /aws_annoying-0.5.0/tests/cli/test_main.py +0 -0
  104. /aws_annoying-0.3.0/tests/utils/test_platform.py → /aws_annoying-0.5.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.5.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,
@@ -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(downloader=TQDMDownloader())
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(downloader=TQDMDownloader())
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
- 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
-
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
- proc = session_manager.start(
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
@@ -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,11 @@
1
+ from .errors import PluginNotInstalledError, SessionManagerError, UnsupportedPlatformError
2
+ from .session_manager import SessionManager
3
+ from .shortcuts import port_forward
4
+
5
+ __all__ = (
6
+ "PluginNotInstalledError",
7
+ "SessionManager",
8
+ "SessionManagerError",
9
+ "UnsupportedPlatformError",
10
+ "port_forward",
11
+ )