aws-annoying 0.5.0__py3-none-any.whl → 0.7.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/app.py +81 -0
- aws_annoying/cli/ecs/__init__.py +3 -0
- aws_annoying/cli/ecs/_app.py +9 -0
- aws_annoying/cli/{ecs_task_definition_lifecycle.py → ecs/task_definition_lifecycle.py} +18 -13
- aws_annoying/cli/ecs/wait_for_deployment.py +158 -0
- aws_annoying/cli/load_variables.py +20 -25
- aws_annoying/cli/logging_handler.py +52 -0
- aws_annoying/cli/main.py +1 -1
- aws_annoying/cli/mfa/configure.py +21 -12
- aws_annoying/cli/session_manager/_common.py +1 -32
- aws_annoying/cli/session_manager/install.py +8 -5
- aws_annoying/cli/session_manager/port_forward.py +22 -12
- aws_annoying/cli/session_manager/start.py +13 -5
- aws_annoying/cli/session_manager/stop.py +9 -7
- aws_annoying/ecs/__init__.py +25 -0
- aws_annoying/ecs/check.py +39 -0
- aws_annoying/ecs/common.py +8 -0
- aws_annoying/ecs/errors.py +14 -0
- aws_annoying/ecs/wait_for.py +190 -0
- aws_annoying/{mfa.py → mfa_config.py} +7 -2
- aws_annoying/session_manager/session_manager.py +2 -4
- aws_annoying/session_manager/shortcuts.py +10 -6
- aws_annoying/utils/downloader.py +1 -8
- aws_annoying/utils/ec2.py +33 -0
- aws_annoying/utils/platform.py +11 -0
- aws_annoying/utils/timeout.py +85 -0
- aws_annoying/{variables.py → variable_loader.py} +11 -16
- {aws_annoying-0.5.0.dist-info → aws_annoying-0.7.0.dist-info}/METADATA +48 -3
- aws_annoying-0.7.0.dist-info/RECORD +42 -0
- aws_annoying-0.5.0.dist-info/RECORD +0 -31
- {aws_annoying-0.5.0.dist-info → aws_annoying-0.7.0.dist-info}/WHEEL +0 -0
- {aws_annoying-0.5.0.dist-info → aws_annoying-0.7.0.dist-info}/entry_points.txt +0 -0
- {aws_annoying-0.5.0.dist-info → aws_annoying-0.7.0.dist-info}/licenses/LICENSE +0 -0
aws_annoying/cli/app.py
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import logging
|
|
5
|
+
import logging.config
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
3
8
|
import typer
|
|
9
|
+
from rich import print # noqa: A004
|
|
10
|
+
from rich.console import Console
|
|
4
11
|
|
|
5
12
|
app = typer.Typer(
|
|
6
13
|
pretty_exceptions_short=True,
|
|
@@ -8,3 +15,77 @@ app = typer.Typer(
|
|
|
8
15
|
rich_markup_mode="rich",
|
|
9
16
|
no_args_is_help=True,
|
|
10
17
|
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def show_version(value: Optional[bool]) -> None:
|
|
21
|
+
"""Show the version of the application."""
|
|
22
|
+
if not value:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
print(importlib.metadata.version("aws-annoying"))
|
|
26
|
+
raise typer.Exit(0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def main( # noqa: D103
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
*,
|
|
33
|
+
version: Optional[bool] = typer.Option( # noqa: ARG001
|
|
34
|
+
None,
|
|
35
|
+
"--version",
|
|
36
|
+
is_eager=True,
|
|
37
|
+
callback=show_version,
|
|
38
|
+
help="Show the version and exit.",
|
|
39
|
+
),
|
|
40
|
+
quiet: bool = typer.Option(
|
|
41
|
+
False, # noqa: FBT003
|
|
42
|
+
help="Disable outputs.",
|
|
43
|
+
),
|
|
44
|
+
verbose: bool = typer.Option(
|
|
45
|
+
False, # noqa: FBT003
|
|
46
|
+
help="Enable verbose outputs.",
|
|
47
|
+
),
|
|
48
|
+
dry_run: bool = typer.Option(
|
|
49
|
+
False, # noqa: FBT003
|
|
50
|
+
help="Enable dry-run mode. If enabled, certain commands will avoid making changes.",
|
|
51
|
+
),
|
|
52
|
+
) -> None:
|
|
53
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
54
|
+
console = Console(soft_wrap=True, emoji=False)
|
|
55
|
+
logging_config: logging.config._DictConfigArgs = {
|
|
56
|
+
"version": 1,
|
|
57
|
+
"disable_existing_loggers": False,
|
|
58
|
+
"formatters": {
|
|
59
|
+
"rich": {
|
|
60
|
+
"format": "%(message)s",
|
|
61
|
+
"datefmt": "[%X]",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"handlers": {
|
|
65
|
+
"null": {
|
|
66
|
+
"class": "logging.NullHandler",
|
|
67
|
+
},
|
|
68
|
+
"rich": {
|
|
69
|
+
"class": "aws_annoying.cli.logging_handler.RichLogHandler",
|
|
70
|
+
"formatter": "rich",
|
|
71
|
+
"console": console,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"root": {
|
|
75
|
+
"handlers": ["null"],
|
|
76
|
+
},
|
|
77
|
+
"loggers": {
|
|
78
|
+
"aws_annoying": {
|
|
79
|
+
"level": log_level,
|
|
80
|
+
"handlers": ["rich"],
|
|
81
|
+
"propagate": True,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
if quiet:
|
|
86
|
+
logging_config["loggers"]["aws_annoying"]["level"] = logging.CRITICAL
|
|
87
|
+
|
|
88
|
+
logging.config.dictConfig(logging_config)
|
|
89
|
+
|
|
90
|
+
# Global flags
|
|
91
|
+
ctx.meta["dry_run"] = dry_run
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from typing import TYPE_CHECKING
|
|
4
5
|
|
|
5
6
|
import boto3
|
|
6
7
|
import typer
|
|
7
|
-
from rich import print # noqa: A004
|
|
8
8
|
|
|
9
|
-
from .
|
|
9
|
+
from ._app import ecs_app
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
12
|
from collections.abc import Iterator
|
|
13
13
|
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
14
17
|
_DELETE_CHUNK_SIZE = 10
|
|
15
18
|
|
|
16
19
|
|
|
17
|
-
@
|
|
18
|
-
def
|
|
20
|
+
@ecs_app.command()
|
|
21
|
+
def task_definition_lifecycle(
|
|
22
|
+
ctx: typer.Context,
|
|
19
23
|
*,
|
|
20
24
|
family: str = typer.Option(
|
|
21
25
|
...,
|
|
@@ -33,14 +37,11 @@ def ecs_task_definition_lifecycle(
|
|
|
33
37
|
False, # noqa: FBT003
|
|
34
38
|
help="Delete the task definition after deregistering it.",
|
|
35
39
|
),
|
|
36
|
-
dry_run: bool = typer.Option(
|
|
37
|
-
False, # noqa: FBT003
|
|
38
|
-
help="Do not perform any changes, only show what would be done.",
|
|
39
|
-
),
|
|
40
40
|
) -> None:
|
|
41
41
|
"""Execute ECS task definition lifecycle."""
|
|
42
|
+
dry_run = ctx.meta["dry_run"]
|
|
42
43
|
if dry_run:
|
|
43
|
-
|
|
44
|
+
logger.info("Dry run mode enabled. Will not perform any actual changes.")
|
|
44
45
|
|
|
45
46
|
ecs = boto3.client("ecs")
|
|
46
47
|
|
|
@@ -59,23 +60,27 @@ def ecs_task_definition_lifecycle(
|
|
|
59
60
|
|
|
60
61
|
# Keep the latest N task definitions
|
|
61
62
|
expired_taskdef_arns = task_definition_arns[:-keep_latest]
|
|
62
|
-
|
|
63
|
+
logger.warning("Deregistering %d task definitions...", len(expired_taskdef_arns))
|
|
63
64
|
for arn in expired_taskdef_arns:
|
|
64
65
|
if not dry_run:
|
|
65
66
|
ecs.deregister_task_definition(taskDefinition=arn)
|
|
66
67
|
|
|
67
68
|
# ARN like: "arn:aws:ecs:<region>:<account-id>:task-definition/<family>:<revision>"
|
|
68
69
|
_, family_revision = arn.split(":task-definition/")
|
|
69
|
-
|
|
70
|
+
logger.warning("Deregistered task definition [yellow]%r[/yellow]", family_revision)
|
|
70
71
|
|
|
71
72
|
if delete and expired_taskdef_arns:
|
|
72
73
|
# Delete the expired task definitions in chunks due to API limitation
|
|
73
|
-
|
|
74
|
+
logger.warning(
|
|
75
|
+
"Deleting %d task definitions in chunks of size %d...",
|
|
76
|
+
len(expired_taskdef_arns),
|
|
77
|
+
_DELETE_CHUNK_SIZE,
|
|
78
|
+
)
|
|
74
79
|
for idx, chunk in enumerate(_chunker(expired_taskdef_arns, _DELETE_CHUNK_SIZE)):
|
|
75
80
|
if not dry_run:
|
|
76
81
|
ecs.delete_task_definitions(taskDefinitions=chunk)
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
logger.warning("Deleted %d task definitions in %d-th batch.", len(chunk), idx)
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
def _chunker(sequence: list, size: int) -> Iterator[list]:
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from aws_annoying.ecs import (
|
|
10
|
+
DeploymentFailedError,
|
|
11
|
+
ECSServiceRef,
|
|
12
|
+
ServiceTaskDefinitionAssertionError,
|
|
13
|
+
check_service_task_definition,
|
|
14
|
+
wait_for_deployment_complete,
|
|
15
|
+
wait_for_deployment_start,
|
|
16
|
+
wait_for_service_stability,
|
|
17
|
+
)
|
|
18
|
+
from aws_annoying.utils.timeout import OperationTimeoutError, Timeout
|
|
19
|
+
|
|
20
|
+
from ._app import ecs_app
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@ecs_app.command()
|
|
26
|
+
def wait_for_deployment( # noqa: PLR0913
|
|
27
|
+
*,
|
|
28
|
+
cluster: str = typer.Option(
|
|
29
|
+
...,
|
|
30
|
+
help="The name of the ECS cluster.",
|
|
31
|
+
show_default=False,
|
|
32
|
+
),
|
|
33
|
+
service: str = typer.Option(
|
|
34
|
+
...,
|
|
35
|
+
help="The name of the ECS service.",
|
|
36
|
+
show_default=False,
|
|
37
|
+
),
|
|
38
|
+
expected_task_definition: Optional[str] = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
help=(
|
|
41
|
+
"The service's task definition expected after deployment."
|
|
42
|
+
" If provided, it will be used to assert the service's task definition after deployment finished or timed out." # noqa: E501
|
|
43
|
+
),
|
|
44
|
+
show_default=False,
|
|
45
|
+
),
|
|
46
|
+
polling_interval: int = typer.Option(
|
|
47
|
+
5,
|
|
48
|
+
help="The interval between any polling attempts, in seconds.",
|
|
49
|
+
min=1,
|
|
50
|
+
),
|
|
51
|
+
timeout_seconds: Optional[int] = typer.Option(
|
|
52
|
+
None,
|
|
53
|
+
help=(
|
|
54
|
+
"The maximum time to wait for the deployment to complete, in seconds."
|
|
55
|
+
" If not provided, it will wait indefinitely."
|
|
56
|
+
),
|
|
57
|
+
min=1,
|
|
58
|
+
),
|
|
59
|
+
wait_for_start: bool = typer.Option(
|
|
60
|
+
True, # noqa: FBT003
|
|
61
|
+
help=(
|
|
62
|
+
"Whether to wait for the deployment to start."
|
|
63
|
+
" Because there could be no deployment right after the deploy,"
|
|
64
|
+
" this option will wait for a new deployment to start if no running deployment is found."
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
wait_for_stability: bool = typer.Option(
|
|
68
|
+
False, # noqa: FBT003
|
|
69
|
+
help="Whether to wait for the service to be stable after the deployment.",
|
|
70
|
+
),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Wait for ECS deployment to complete."""
|
|
73
|
+
start = datetime.now(tz=timezone.utc)
|
|
74
|
+
try:
|
|
75
|
+
with Timeout(timeout_seconds):
|
|
76
|
+
_wait_for_deployment(
|
|
77
|
+
ECSServiceRef(cluster=cluster, service=service),
|
|
78
|
+
wait_for_start=wait_for_start,
|
|
79
|
+
polling_interval=polling_interval,
|
|
80
|
+
wait_for_stability=wait_for_stability,
|
|
81
|
+
expected_task_definition=expected_task_definition,
|
|
82
|
+
)
|
|
83
|
+
except OperationTimeoutError:
|
|
84
|
+
logger.error( # noqa: TRY400
|
|
85
|
+
"Timeout reached after %s seconds. The deployment may not have finished.",
|
|
86
|
+
timeout_seconds,
|
|
87
|
+
)
|
|
88
|
+
raise typer.Exit(1) from None
|
|
89
|
+
except DeploymentFailedError as err:
|
|
90
|
+
elapsed = datetime.now(tz=timezone.utc) - start
|
|
91
|
+
logger.error( # noqa: TRY400
|
|
92
|
+
"Deployment failed in [bold]%.2f[/bold] seconds with error: %s",
|
|
93
|
+
elapsed.total_seconds(),
|
|
94
|
+
err,
|
|
95
|
+
)
|
|
96
|
+
raise typer.Exit(1) from None
|
|
97
|
+
else:
|
|
98
|
+
elapsed = datetime.now(tz=timezone.utc) - start
|
|
99
|
+
logger.info(
|
|
100
|
+
"Deployment completed in [bold]%.2f[/bold] seconds.",
|
|
101
|
+
elapsed.total_seconds(),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _wait_for_deployment(
|
|
106
|
+
service_ref: ECSServiceRef,
|
|
107
|
+
*,
|
|
108
|
+
wait_for_start: bool,
|
|
109
|
+
polling_interval: int = 5,
|
|
110
|
+
wait_for_stability: bool,
|
|
111
|
+
expected_task_definition: str | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
# Find current deployment for the service
|
|
114
|
+
logger.info(
|
|
115
|
+
"Looking up running deployment for service %s",
|
|
116
|
+
service_ref.service,
|
|
117
|
+
)
|
|
118
|
+
latest_deployment_arn = wait_for_deployment_start(
|
|
119
|
+
service_ref,
|
|
120
|
+
wait_for_start=wait_for_start,
|
|
121
|
+
polling_interval=polling_interval,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Polling for the deployment to finish (successfully or unsuccessfully)
|
|
125
|
+
logger.info(
|
|
126
|
+
"Start waiting for deployment %s to finish.",
|
|
127
|
+
latest_deployment_arn,
|
|
128
|
+
)
|
|
129
|
+
ok, status = wait_for_deployment_complete(latest_deployment_arn, polling_interval=polling_interval)
|
|
130
|
+
if ok:
|
|
131
|
+
logger.info(
|
|
132
|
+
"Deployment succeeded with status %s",
|
|
133
|
+
status,
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
msg = f"Deployment failed with status: {status}"
|
|
137
|
+
raise DeploymentFailedError(msg)
|
|
138
|
+
|
|
139
|
+
# Wait for the service to be stable
|
|
140
|
+
if wait_for_stability:
|
|
141
|
+
logger.info(
|
|
142
|
+
"Start waiting for service %s to be stable.",
|
|
143
|
+
service_ref.service,
|
|
144
|
+
)
|
|
145
|
+
wait_for_service_stability(service_ref, polling_interval=polling_interval)
|
|
146
|
+
|
|
147
|
+
# Check if the service task definition matches the expected one
|
|
148
|
+
if expected_task_definition:
|
|
149
|
+
logger.info(
|
|
150
|
+
"Checking if the service task definition is the expected one: %s",
|
|
151
|
+
expected_task_definition,
|
|
152
|
+
)
|
|
153
|
+
ok, actual = check_service_task_definition(service_ref, expect=expected_task_definition)
|
|
154
|
+
if not ok:
|
|
155
|
+
msg = f"The service task definition is not the expected one; got: {actual!r}"
|
|
156
|
+
raise ServiceTaskDefinitionAssertionError(msg)
|
|
157
|
+
|
|
158
|
+
logger.info("The service task definition matches the expected one.")
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# flake8: noqa: B008
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
7
|
+
from io import StringIO
|
|
6
8
|
from typing import NoReturn, Optional
|
|
7
9
|
|
|
8
10
|
import typer
|
|
9
11
|
from rich.console import Console
|
|
10
12
|
from rich.table import Table
|
|
11
13
|
|
|
12
|
-
from aws_annoying.
|
|
14
|
+
from aws_annoying.variable_loader import VariableLoader
|
|
13
15
|
|
|
14
16
|
from .app import app
|
|
15
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
@app.command(
|
|
18
22
|
context_settings={
|
|
@@ -21,9 +25,9 @@ from .app import app
|
|
|
21
25
|
"ignore_unknown_options": True,
|
|
22
26
|
},
|
|
23
27
|
)
|
|
24
|
-
def load_variables(
|
|
25
|
-
*,
|
|
28
|
+
def load_variables(
|
|
26
29
|
ctx: typer.Context,
|
|
30
|
+
*,
|
|
27
31
|
arns: list[str] = typer.Option(
|
|
28
32
|
[],
|
|
29
33
|
metavar="ARN",
|
|
@@ -42,14 +46,6 @@ def load_variables( # noqa: PLR0913
|
|
|
42
46
|
False, # noqa: FBT003
|
|
43
47
|
help="Overwrite the existing environment variables with the same name.",
|
|
44
48
|
),
|
|
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
49
|
replace: bool = typer.Option(
|
|
54
50
|
True, # noqa: FBT003
|
|
55
51
|
help=(
|
|
@@ -82,21 +78,19 @@ def load_variables( # noqa: PLR0913
|
|
|
82
78
|
The variables are loaded in the order of option provided, overwriting the variables with the same name in the order of the ARNs.
|
|
83
79
|
Existing environment variables are preserved by default, unless `--overwrite-env` is provided.
|
|
84
80
|
""" # noqa: E501
|
|
85
|
-
console = Console(quiet=quiet, emoji=False)
|
|
86
|
-
|
|
87
81
|
command = ctx.args
|
|
88
82
|
if not command:
|
|
89
|
-
|
|
83
|
+
logger.warning("No command provided. Exiting...")
|
|
90
84
|
raise typer.Exit(0)
|
|
91
85
|
|
|
92
86
|
# Mapping of the ARNs by index (index used for ordering)
|
|
93
87
|
map_arns_by_index = {str(idx): arn for idx, arn in enumerate(arns)}
|
|
94
88
|
if env_prefix:
|
|
95
|
-
|
|
89
|
+
logger.info("Loading ARNs from environment variables with prefix: %r", env_prefix)
|
|
96
90
|
arns_env = {
|
|
97
91
|
key.removeprefix(env_prefix): value for key, value in os.environ.items() if key.startswith(env_prefix)
|
|
98
92
|
}
|
|
99
|
-
|
|
93
|
+
logger.info("Found %d sources from environment variables.", len(arns_env))
|
|
100
94
|
map_arns_by_index = arns_env | map_arns_by_index
|
|
101
95
|
|
|
102
96
|
# Briefly show the ARNs
|
|
@@ -104,21 +98,22 @@ def load_variables( # noqa: PLR0913
|
|
|
104
98
|
for idx, arn in sorted(map_arns_by_index.items()):
|
|
105
99
|
table.add_row(idx, arn)
|
|
106
100
|
|
|
107
|
-
|
|
101
|
+
# Workaround: The logger cannot directly handle the rich table output.
|
|
102
|
+
with StringIO() as file:
|
|
103
|
+
Console(file=file, emoji=False).print(table)
|
|
104
|
+
table_str = file.getvalue().rstrip()
|
|
105
|
+
logger.info("Summary:\n%s", table_str)
|
|
108
106
|
|
|
109
107
|
# Retrieve the variables
|
|
110
|
-
loader = VariableLoader(
|
|
111
|
-
|
|
112
|
-
if dry_run:
|
|
113
|
-
console.print("⚠️ Dry run mode enabled. Variables won't be loaded from AWS.")
|
|
114
|
-
|
|
108
|
+
loader = VariableLoader()
|
|
109
|
+
logger.info("Retrieving variables from AWS resources...")
|
|
115
110
|
try:
|
|
116
111
|
variables, load_stats = loader.load(map_arns_by_index)
|
|
117
112
|
except Exception as exc: # noqa: BLE001
|
|
118
|
-
|
|
113
|
+
logger.error("Failed to load the variables: %s", exc) # noqa: TRY400
|
|
119
114
|
raise typer.Exit(1) from None
|
|
120
115
|
|
|
121
|
-
|
|
116
|
+
logger.info("Retrieved %d secrets and %d parameters.", load_stats["secrets"], load_stats["parameters"])
|
|
122
117
|
|
|
123
118
|
# Prepare the environment variables
|
|
124
119
|
env = os.environ.copy()
|
|
@@ -130,7 +125,7 @@ def load_variables( # noqa: PLR0913
|
|
|
130
125
|
env.setdefault(key, str(value))
|
|
131
126
|
|
|
132
127
|
# Run the command with the variables injected as environment variables, replacing current process
|
|
133
|
-
|
|
128
|
+
logger.info("Running the command: [bold orchid]%s[/bold orchid]", " ".join(command))
|
|
134
129
|
if replace: # pragma: no cover (not coverable)
|
|
135
130
|
os.execvpe(command[0], command, env=env) # noqa: S606
|
|
136
131
|
# The above line should never return
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import logging.config
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RichLogHandler(logging.Handler):
|
|
14
|
+
"""Custom logging handler to use Rich Console."""
|
|
15
|
+
|
|
16
|
+
_level_emojis: Final[dict[str, str]] = {
|
|
17
|
+
"DEBUG": "🔍",
|
|
18
|
+
"INFO": "ℹ️", # noqa: RUF001
|
|
19
|
+
"WARNING": "⚠️",
|
|
20
|
+
"ERROR": "❗",
|
|
21
|
+
"CRITICAL": "🚨",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def __init__(self, console: Console, *args: Any, **kwargs: Any) -> None:
|
|
25
|
+
"""Initialize the log handler.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
console: Rich console instance.
|
|
29
|
+
*args: Additional arguments for the logging handler.
|
|
30
|
+
**kwargs: Additional keyword arguments for the logging handler.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
self.console = console
|
|
34
|
+
|
|
35
|
+
@override
|
|
36
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
37
|
+
msg = self.format(record)
|
|
38
|
+
self.console.print(msg)
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
42
|
+
"""Format the log record.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
record: The log record to format.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The formatted log message.
|
|
49
|
+
"""
|
|
50
|
+
msg = super().format(record)
|
|
51
|
+
emoji = self._level_emojis.get(record.levelname)
|
|
52
|
+
return f"{emoji} {msg}" if emoji else msg
|
aws_annoying/cli/main.py
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from pathlib import Path # noqa: TC003
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
7
|
import boto3
|
|
7
8
|
import typer
|
|
8
|
-
from rich import print # noqa: A004
|
|
9
9
|
from rich.prompt import Prompt
|
|
10
10
|
|
|
11
|
-
from aws_annoying.
|
|
11
|
+
from aws_annoying.mfa_config import MfaConfig, update_credentials
|
|
12
12
|
|
|
13
13
|
from ._app import mfa_app
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@mfa_app.command()
|
|
@@ -44,6 +44,10 @@ def configure( # noqa: PLR0913
|
|
|
44
44
|
"~/.aws/config",
|
|
45
45
|
help="The path to the AWS config file. Used to persist the MFA configuration.",
|
|
46
46
|
),
|
|
47
|
+
aws_config_section: str = typer.Option(
|
|
48
|
+
"aws-annoying:mfa",
|
|
49
|
+
help="The section in the AWS config file to persist the MFA configuration.",
|
|
50
|
+
),
|
|
47
51
|
persist: bool = typer.Option(
|
|
48
52
|
True, # noqa: FBT003
|
|
49
53
|
help="Persist the MFA configuration.",
|
|
@@ -55,9 +59,9 @@ def configure( # noqa: PLR0913
|
|
|
55
59
|
aws_config = aws_config.expanduser()
|
|
56
60
|
|
|
57
61
|
# Load configuration
|
|
58
|
-
mfa_config, exists = MfaConfig.from_ini_file(aws_config,
|
|
62
|
+
mfa_config, exists = MfaConfig.from_ini_file(aws_config, aws_config_section)
|
|
59
63
|
if exists:
|
|
60
|
-
|
|
64
|
+
logger.info("Loaded MFA configuration from AWS config (%s).", aws_config)
|
|
61
65
|
|
|
62
66
|
mfa_profile = (
|
|
63
67
|
mfa_profile
|
|
@@ -83,7 +87,7 @@ def configure( # noqa: PLR0913
|
|
|
83
87
|
)
|
|
84
88
|
|
|
85
89
|
# Get credentials
|
|
86
|
-
|
|
90
|
+
logger.info("Retrieving MFA credentials using profile [bold]%s[/bold]", mfa_source_profile)
|
|
87
91
|
session = boto3.session.Session(profile_name=mfa_source_profile)
|
|
88
92
|
sts = session.client("sts")
|
|
89
93
|
response = sts.get_session_token(
|
|
@@ -93,7 +97,11 @@ def configure( # noqa: PLR0913
|
|
|
93
97
|
credentials = response["Credentials"]
|
|
94
98
|
|
|
95
99
|
# Update MFA profile in AWS credentials
|
|
96
|
-
|
|
100
|
+
logger.warning(
|
|
101
|
+
"Updating MFA profile ([bold]%s[/bold]) to AWS credentials ([bold]%s[/bold])",
|
|
102
|
+
mfa_profile,
|
|
103
|
+
aws_credentials,
|
|
104
|
+
)
|
|
97
105
|
update_credentials(
|
|
98
106
|
aws_credentials,
|
|
99
107
|
mfa_profile, # type: ignore[arg-type]
|
|
@@ -104,13 +112,14 @@ def configure( # noqa: PLR0913
|
|
|
104
112
|
|
|
105
113
|
# Persist MFA configuration
|
|
106
114
|
if persist:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
logger.info(
|
|
116
|
+
"Persisting MFA configuration in AWS config (%s), in [bold]%s[/bold] section.",
|
|
117
|
+
aws_config,
|
|
118
|
+
aws_config_section,
|
|
110
119
|
)
|
|
111
120
|
mfa_config.mfa_profile = mfa_profile
|
|
112
121
|
mfa_config.mfa_source_profile = mfa_source_profile
|
|
113
122
|
mfa_config.mfa_serial_number = mfa_serial_number
|
|
114
|
-
mfa_config.save_ini_file(aws_config,
|
|
123
|
+
mfa_config.save_ini_file(aws_config, aws_config_section)
|
|
115
124
|
else:
|
|
116
|
-
|
|
125
|
+
logger.warning("MFA configuration not persisted.")
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
# TODO(lasuillard): Using this file until split CLI from library codebase
|
|
2
1
|
from __future__ import annotations
|
|
3
2
|
|
|
4
|
-
import re
|
|
5
3
|
from typing import Any
|
|
6
4
|
|
|
7
|
-
import boto3
|
|
8
5
|
import typer
|
|
9
6
|
from rich.prompt import Confirm
|
|
10
7
|
|
|
@@ -17,38 +14,10 @@ class SessionManager(_SessionManager):
|
|
|
17
14
|
if self._confirm:
|
|
18
15
|
return
|
|
19
16
|
|
|
20
|
-
confirm = Confirm.ask(f"
|
|
17
|
+
confirm = Confirm.ask(f"Will run the following command: [bold red]{' '.join(command)}[/bold red]. Proceed?")
|
|
21
18
|
if not confirm:
|
|
22
19
|
raise typer.Abort
|
|
23
20
|
|
|
24
21
|
def install(self, *args: Any, confirm: bool = False, **kwargs: Any) -> None:
|
|
25
22
|
self._confirm = confirm
|
|
26
23
|
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"])
|