aws-annoying 0.6.0__py3-none-any.whl → 0.8.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 +30 -1
- aws_annoying/cli/ecs/task_definition_lifecycle.py +0 -3
- aws_annoying/cli/ecs/wait_for_deployment.py +67 -3
- aws_annoying/cli/load_variables.py +0 -5
- aws_annoying/cli/logging_handler.py +3 -3
- aws_annoying/cli/mfa/configure.py +13 -8
- aws_annoying/cli/session_manager/install.py +6 -2
- aws_annoying/cli/session_manager/port_forward.py +21 -11
- aws_annoying/cli/session_manager/start.py +5 -1
- aws_annoying/cli/session_manager/stop.py +9 -3
- aws_annoying/ecs/__init__.py +10 -2
- aws_annoying/ecs/check.py +39 -0
- aws_annoying/ecs/wait_for.py +190 -0
- aws_annoying/utils/downloader.py +1 -8
- aws_annoying/utils/ec2.py +1 -4
- aws_annoying/utils/timeout.py +2 -5
- {aws_annoying-0.6.0.dist-info → aws_annoying-0.8.0.dist-info}/METADATA +2 -2
- {aws_annoying-0.6.0.dist-info → aws_annoying-0.8.0.dist-info}/RECORD +21 -20
- aws_annoying/ecs/deployment_waiter.py +0 -274
- {aws_annoying-0.6.0.dist-info → aws_annoying-0.8.0.dist-info}/WHEEL +0 -0
- {aws_annoying-0.6.0.dist-info → aws_annoying-0.8.0.dist-info}/entry_points.txt +0 -0
- {aws_annoying-0.6.0.dist-info → aws_annoying-0.8.0.dist-info}/licenses/LICENSE +0 -0
aws_annoying/cli/app.py
CHANGED
|
@@ -8,6 +8,10 @@ from typing import Optional
|
|
|
8
8
|
import typer
|
|
9
9
|
from rich import print # noqa: A004
|
|
10
10
|
from rich.console import Console
|
|
11
|
+
from rich.highlighter import ReprHighlighter
|
|
12
|
+
from rich.theme import Theme
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
11
15
|
|
|
12
16
|
app = typer.Typer(
|
|
13
17
|
pretty_exceptions_short=True,
|
|
@@ -51,7 +55,7 @@ def main( # noqa: D103
|
|
|
51
55
|
),
|
|
52
56
|
) -> None:
|
|
53
57
|
log_level = logging.DEBUG if verbose else logging.INFO
|
|
54
|
-
console =
|
|
58
|
+
console = _get_console()
|
|
55
59
|
logging_config: logging.config._DictConfigArgs = {
|
|
56
60
|
"version": 1,
|
|
57
61
|
"disable_existing_loggers": False,
|
|
@@ -89,3 +93,28 @@ def main( # noqa: D103
|
|
|
89
93
|
|
|
90
94
|
# Global flags
|
|
91
95
|
ctx.meta["dry_run"] = dry_run
|
|
96
|
+
if dry_run:
|
|
97
|
+
logger.warning("Dry run mode enabled. Some operation may behave differently to avoid making changes.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_console() -> Console:
|
|
101
|
+
theme = Theme(
|
|
102
|
+
{
|
|
103
|
+
"repr.arn": "bold orange3",
|
|
104
|
+
"repr.constant": "bold blue",
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
return Console(soft_wrap=True, emoji=False, highlighter=CustomHighlighter(), theme=theme)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CustomHighlighter(ReprHighlighter):
|
|
111
|
+
"""Custom highlighter to handle additional patterns."""
|
|
112
|
+
|
|
113
|
+
highlights = [ # noqa: RUF012
|
|
114
|
+
*ReprHighlighter.highlights,
|
|
115
|
+
# AWS Resource Name; https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html
|
|
116
|
+
# NOTE: Quite simplified regex, may not cover all cases.
|
|
117
|
+
r"(?P<arn>\barn:[0-9a-zA-Z/+=,\.@_\-:]+\b)",
|
|
118
|
+
# Constants
|
|
119
|
+
r"(?P<constant>\b[A-Z_]+\b)",
|
|
120
|
+
]
|
|
@@ -40,9 +40,6 @@ def task_definition_lifecycle(
|
|
|
40
40
|
) -> None:
|
|
41
41
|
"""Execute ECS task definition lifecycle."""
|
|
42
42
|
dry_run = ctx.meta["dry_run"]
|
|
43
|
-
if dry_run:
|
|
44
|
-
logger.info("Dry run mode enabled. Will not perform any actual changes.")
|
|
45
|
-
|
|
46
43
|
ecs = boto3.client("ecs")
|
|
47
44
|
|
|
48
45
|
# Get all task definitions for the family
|
|
@@ -6,7 +6,15 @@ from typing import Optional
|
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from aws_annoying.ecs import
|
|
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
|
+
)
|
|
10
18
|
from aws_annoying.utils.timeout import OperationTimeoutError, Timeout
|
|
11
19
|
|
|
12
20
|
from ._app import ecs_app
|
|
@@ -63,10 +71,10 @@ def wait_for_deployment( # noqa: PLR0913
|
|
|
63
71
|
) -> None:
|
|
64
72
|
"""Wait for ECS deployment to complete."""
|
|
65
73
|
start = datetime.now(tz=timezone.utc)
|
|
66
|
-
waiter = ECSDeploymentWaiter(ECSServiceRef(cluster=cluster, service=service))
|
|
67
74
|
try:
|
|
68
75
|
with Timeout(timeout_seconds):
|
|
69
|
-
|
|
76
|
+
_wait_for_deployment(
|
|
77
|
+
ECSServiceRef(cluster=cluster, service=service),
|
|
70
78
|
wait_for_start=wait_for_start,
|
|
71
79
|
polling_interval=polling_interval,
|
|
72
80
|
wait_for_stability=wait_for_stability,
|
|
@@ -92,3 +100,59 @@ def wait_for_deployment( # noqa: PLR0913
|
|
|
92
100
|
"Deployment completed in [bold]%.2f[/bold] seconds.",
|
|
93
101
|
elapsed.total_seconds(),
|
|
94
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.")
|
|
@@ -78,8 +78,6 @@ def load_variables(
|
|
|
78
78
|
The variables are loaded in the order of option provided, overwriting the variables with the same name in the order of the ARNs.
|
|
79
79
|
Existing environment variables are preserved by default, unless `--overwrite-env` is provided.
|
|
80
80
|
""" # noqa: E501
|
|
81
|
-
dry_run = ctx.meta["dry_run"]
|
|
82
|
-
|
|
83
81
|
command = ctx.args
|
|
84
82
|
if not command:
|
|
85
83
|
logger.warning("No command provided. Exiting...")
|
|
@@ -109,9 +107,6 @@ def load_variables(
|
|
|
109
107
|
# Retrieve the variables
|
|
110
108
|
loader = VariableLoader()
|
|
111
109
|
logger.info("Retrieving variables from AWS resources...")
|
|
112
|
-
if dry_run:
|
|
113
|
-
logger.warning("Dry run mode enabled. Variables won't be loaded from AWS.")
|
|
114
|
-
|
|
115
110
|
try:
|
|
116
111
|
variables, load_stats = loader.load(map_arns_by_index)
|
|
117
112
|
except Exception as exc: # noqa: BLE001
|
|
@@ -15,10 +15,10 @@ class RichLogHandler(logging.Handler):
|
|
|
15
15
|
|
|
16
16
|
_level_emojis: Final[dict[str, str]] = {
|
|
17
17
|
"DEBUG": "🔍",
|
|
18
|
-
"INFO": "
|
|
18
|
+
"INFO": "🔔",
|
|
19
19
|
"WARNING": "⚠️",
|
|
20
|
-
"ERROR": "
|
|
21
|
-
"CRITICAL": "
|
|
20
|
+
"ERROR": "🚨",
|
|
21
|
+
"CRITICAL": "🔥",
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
def __init__(self, console: Console, *args: Any, **kwargs: Any) -> None:
|
|
@@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
|
|
17
17
|
|
|
18
18
|
@mfa_app.command()
|
|
19
19
|
def configure( # noqa: PLR0913
|
|
20
|
+
ctx: typer.Context,
|
|
20
21
|
*,
|
|
21
22
|
mfa_profile: Optional[str] = typer.Option(
|
|
22
23
|
None,
|
|
@@ -54,6 +55,8 @@ def configure( # noqa: PLR0913
|
|
|
54
55
|
),
|
|
55
56
|
) -> None:
|
|
56
57
|
"""Configure AWS profile for MFA."""
|
|
58
|
+
dry_run = ctx.meta["dry_run"]
|
|
59
|
+
|
|
57
60
|
# Expand user home directory
|
|
58
61
|
aws_credentials = aws_credentials.expanduser()
|
|
59
62
|
aws_config = aws_config.expanduser()
|
|
@@ -102,13 +105,14 @@ def configure( # noqa: PLR0913
|
|
|
102
105
|
mfa_profile,
|
|
103
106
|
aws_credentials,
|
|
104
107
|
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
if not dry_run:
|
|
109
|
+
update_credentials(
|
|
110
|
+
aws_credentials,
|
|
111
|
+
mfa_profile, # type: ignore[arg-type]
|
|
112
|
+
access_key=credentials["AccessKeyId"],
|
|
113
|
+
secret_key=credentials["SecretAccessKey"],
|
|
114
|
+
session_token=credentials["SessionToken"],
|
|
115
|
+
)
|
|
112
116
|
|
|
113
117
|
# Persist MFA configuration
|
|
114
118
|
if persist:
|
|
@@ -120,6 +124,7 @@ def configure( # noqa: PLR0913
|
|
|
120
124
|
mfa_config.mfa_profile = mfa_profile
|
|
121
125
|
mfa_config.mfa_source_profile = mfa_source_profile
|
|
122
126
|
mfa_config.mfa_serial_number = mfa_serial_number
|
|
123
|
-
|
|
127
|
+
if not dry_run:
|
|
128
|
+
mfa_config.save_ini_file(aws_config, aws_config_section)
|
|
124
129
|
else:
|
|
125
130
|
logger.warning("MFA configuration not persisted.")
|
|
@@ -15,12 +15,15 @@ logger = logging.getLogger(__name__)
|
|
|
15
15
|
# https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
|
|
16
16
|
@session_manager_app.command()
|
|
17
17
|
def install(
|
|
18
|
-
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
*,
|
|
20
|
+
yes: bool = typer.Option(
|
|
19
21
|
False, # noqa: FBT003
|
|
20
22
|
help="Do not ask confirmation for installation.",
|
|
21
23
|
),
|
|
22
24
|
) -> None:
|
|
23
25
|
"""Install AWS Session Manager plugin."""
|
|
26
|
+
dry_run = ctx.meta["dry_run"]
|
|
24
27
|
session_manager = SessionManager()
|
|
25
28
|
|
|
26
29
|
# Check session-manager-plugin already installed
|
|
@@ -31,7 +34,8 @@ def install(
|
|
|
31
34
|
|
|
32
35
|
# Install session-manager-plugin
|
|
33
36
|
logger.warning("Installing AWS Session Manager plugin. You could be prompted for admin privileges request.")
|
|
34
|
-
|
|
37
|
+
if not dry_run:
|
|
38
|
+
session_manager.install(confirm=yes, downloader=TQDMDownloader())
|
|
35
39
|
|
|
36
40
|
# Verify installation
|
|
37
41
|
is_installed, binary_path, version = session_manager.verify_installation()
|
|
@@ -19,6 +19,8 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
# https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
|
|
20
20
|
@session_manager_app.command()
|
|
21
21
|
def port_forward( # noqa: PLR0913
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
*,
|
|
22
24
|
# TODO(lasuillard): Add `--local-host` option, redirect the traffic to non-localhost bind (unsupported by AWS)
|
|
23
25
|
local_port: int = typer.Option(
|
|
24
26
|
...,
|
|
@@ -48,7 +50,7 @@ def port_forward( # noqa: PLR0913
|
|
|
48
50
|
"./session-manager-plugin.pid",
|
|
49
51
|
help="The path to the PID file to store the process ID of the session manager plugin.",
|
|
50
52
|
),
|
|
51
|
-
terminate_running_process: bool = typer.Option(
|
|
53
|
+
terminate_running_process: bool = typer.Option(
|
|
52
54
|
False, # noqa: FBT003
|
|
53
55
|
help="Terminate the process in the PID file if it already exists.",
|
|
54
56
|
),
|
|
@@ -58,6 +60,7 @@ def port_forward( # noqa: PLR0913
|
|
|
58
60
|
),
|
|
59
61
|
) -> None:
|
|
60
62
|
"""Start a port forwarding session using AWS Session Manager."""
|
|
63
|
+
dry_run = ctx.meta["dry_run"]
|
|
61
64
|
session_manager = SessionManager()
|
|
62
65
|
|
|
63
66
|
# Check if the PID file already exists
|
|
@@ -86,7 +89,7 @@ def port_forward( # noqa: PLR0913
|
|
|
86
89
|
logger.info("Instance ID resolved: [bold]%s[/bold]", instance_id)
|
|
87
90
|
target = instance_id
|
|
88
91
|
else:
|
|
89
|
-
logger.
|
|
92
|
+
logger.error("Instance with name '%s' not found.", through)
|
|
90
93
|
raise typer.Exit(1)
|
|
91
94
|
|
|
92
95
|
# Initiate the session
|
|
@@ -111,19 +114,26 @@ def port_forward( # noqa: PLR0913
|
|
|
111
114
|
through,
|
|
112
115
|
reason,
|
|
113
116
|
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
if not dry_run:
|
|
118
|
+
proc = subprocess.Popen( # noqa: S603
|
|
119
|
+
command,
|
|
120
|
+
stdout=stdout,
|
|
121
|
+
stderr=subprocess.STDOUT,
|
|
122
|
+
text=True,
|
|
123
|
+
close_fds=False, # FD inherited from parent process
|
|
124
|
+
)
|
|
125
|
+
pid = proc.pid
|
|
126
|
+
else:
|
|
127
|
+
pid = -1
|
|
128
|
+
|
|
121
129
|
logger.info(
|
|
122
130
|
"Session Manager Plugin started with PID %d. Outputs will be logged to %s.",
|
|
123
|
-
|
|
131
|
+
pid,
|
|
124
132
|
log_file.absolute(),
|
|
125
133
|
)
|
|
126
134
|
|
|
127
135
|
# Write the PID to the file
|
|
128
|
-
|
|
136
|
+
if not dry_run:
|
|
137
|
+
pid_file.write_text(str(pid))
|
|
138
|
+
|
|
129
139
|
logger.info("PID file written to %s.", pid_file.absolute())
|
|
@@ -18,6 +18,8 @@ logger = logging.getLogger(__name__)
|
|
|
18
18
|
|
|
19
19
|
@session_manager_app.command()
|
|
20
20
|
def start(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
*,
|
|
21
23
|
target: str = typer.Option(
|
|
22
24
|
...,
|
|
23
25
|
show_default=False,
|
|
@@ -29,6 +31,7 @@ def start(
|
|
|
29
31
|
),
|
|
30
32
|
) -> None:
|
|
31
33
|
"""Start new session."""
|
|
34
|
+
dry_run = ctx.meta["dry_run"]
|
|
32
35
|
session_manager = SessionManager()
|
|
33
36
|
|
|
34
37
|
# Resolve the instance name or ID
|
|
@@ -52,4 +55,5 @@ def start(
|
|
|
52
55
|
parameters={},
|
|
53
56
|
reason=reason,
|
|
54
57
|
)
|
|
55
|
-
|
|
58
|
+
if not dry_run:
|
|
59
|
+
os.execvp(command[0], command) # noqa: S606
|
|
@@ -14,16 +14,20 @@ logger = logging.getLogger(__name__)
|
|
|
14
14
|
|
|
15
15
|
@session_manager_app.command()
|
|
16
16
|
def stop(
|
|
17
|
+
ctx: typer.Context,
|
|
18
|
+
*,
|
|
17
19
|
pid_file: Path = typer.Option( # noqa: B008
|
|
18
20
|
"./session-manager-plugin.pid",
|
|
19
21
|
help="The path to the PID file to store the process ID of the session manager plugin.",
|
|
20
22
|
),
|
|
21
|
-
remove: bool = typer.Option(
|
|
23
|
+
remove: bool = typer.Option(
|
|
22
24
|
True, # noqa: FBT003
|
|
23
25
|
help="Remove the PID file after stopping the session.",
|
|
24
26
|
),
|
|
25
27
|
) -> None:
|
|
26
28
|
"""Stop running session for PID file."""
|
|
29
|
+
dry_run = ctx.meta["dry_run"]
|
|
30
|
+
|
|
27
31
|
# Check if PID file exists
|
|
28
32
|
if not pid_file.is_file():
|
|
29
33
|
logger.error("PID file not found: %s", pid_file)
|
|
@@ -40,13 +44,15 @@ def stop(
|
|
|
40
44
|
# Send SIGTERM to the process
|
|
41
45
|
try:
|
|
42
46
|
logger.warning("Terminating running process with PID %d.", pid)
|
|
43
|
-
|
|
47
|
+
if not dry_run:
|
|
48
|
+
os.kill(pid, signal.SIGTERM)
|
|
44
49
|
except ProcessLookupError:
|
|
45
50
|
logger.warning("Tried to terminate process with PID %d but does not exist.", pid)
|
|
46
51
|
|
|
47
52
|
# Remove the PID file
|
|
48
53
|
if remove:
|
|
49
54
|
logger.info("Removed the PID file %s.", pid_file)
|
|
50
|
-
|
|
55
|
+
if not dry_run:
|
|
56
|
+
pid_file.unlink()
|
|
51
57
|
|
|
52
58
|
logger.info("Terminated the session successfully.")
|
aws_annoying/ecs/__init__.py
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
|
+
from .check import check_service_task_definition
|
|
1
2
|
from .common import ECSServiceRef
|
|
2
|
-
from .deployment_waiter import ECSDeploymentWaiter
|
|
3
3
|
from .errors import (
|
|
4
4
|
DeploymentFailedError,
|
|
5
5
|
NoRunningDeploymentError,
|
|
6
6
|
ServiceTaskDefinitionAssertionError,
|
|
7
7
|
WaitForDeploymentError,
|
|
8
8
|
)
|
|
9
|
+
from .wait_for import (
|
|
10
|
+
wait_for_deployment_complete,
|
|
11
|
+
wait_for_deployment_start,
|
|
12
|
+
wait_for_service_stability,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
__all__ = (
|
|
11
16
|
"DeploymentFailedError",
|
|
12
|
-
"ECSDeploymentWaiter",
|
|
13
17
|
"ECSServiceRef",
|
|
14
18
|
"NoRunningDeploymentError",
|
|
15
19
|
"ServiceTaskDefinitionAssertionError",
|
|
16
20
|
"WaitForDeploymentError",
|
|
21
|
+
"check_service_task_definition",
|
|
22
|
+
"wait_for_deployment_complete",
|
|
23
|
+
"wait_for_deployment_start",
|
|
24
|
+
"wait_for_service_stability",
|
|
17
25
|
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .common import ECSServiceRef
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_service_task_definition(
|
|
15
|
+
service_ref: ECSServiceRef,
|
|
16
|
+
*,
|
|
17
|
+
session: boto3.session.Session | None = None,
|
|
18
|
+
expect: str,
|
|
19
|
+
) -> tuple[bool, str]:
|
|
20
|
+
"""Check the service's current task definition matches the expected one.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
service_ref: The ECS service reference containing the cluster and service names.
|
|
24
|
+
session: The boto3 session to use for the ECS client.
|
|
25
|
+
expect: The ARN of expected task definition.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A tuple containing a boolean indicating whether the task definition matches the expected one
|
|
29
|
+
and the current task definition ARN.
|
|
30
|
+
"""
|
|
31
|
+
session = session or boto3.session.Session()
|
|
32
|
+
ecs = session.client("ecs")
|
|
33
|
+
|
|
34
|
+
service_detail = ecs.describe_services(cluster=service_ref.cluster, services=[service_ref.service])["services"][0]
|
|
35
|
+
current_task_definition_arn = service_detail["taskDefinition"]
|
|
36
|
+
if current_task_definition_arn != expect:
|
|
37
|
+
return (False, current_task_definition_arn)
|
|
38
|
+
|
|
39
|
+
return (True, current_task_definition_arn)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from time import sleep
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
import botocore.exceptions
|
|
10
|
+
|
|
11
|
+
from .errors import NoRunningDeploymentError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .common import ECSServiceRef
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def wait_for_deployment_start(
|
|
20
|
+
service_ref: ECSServiceRef,
|
|
21
|
+
*,
|
|
22
|
+
session: boto3.session.Session | None = None,
|
|
23
|
+
wait_for_start: bool,
|
|
24
|
+
polling_interval: int = 5,
|
|
25
|
+
max_attempts: int | None = None,
|
|
26
|
+
) -> str:
|
|
27
|
+
"""Wait for the ECS deployment to start.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
service_ref: The ECS service reference containing the cluster and service names.
|
|
31
|
+
session: The boto3 session to use for the ECS client.
|
|
32
|
+
wait_for_start: Whether to wait for the deployment to start.
|
|
33
|
+
polling_interval: The interval between any polling attempts, in seconds.
|
|
34
|
+
max_attempts: The maximum number of attempts to wait for the deployment to start.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
NoRunningDeploymentError: If no running deployments are found and `wait_for_start` is False.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The ARN of the latest deployment for the service.
|
|
41
|
+
"""
|
|
42
|
+
session = session or boto3.session.Session()
|
|
43
|
+
ecs = session.client("ecs")
|
|
44
|
+
|
|
45
|
+
if wait_for_start:
|
|
46
|
+
logger.warning("`wait_for_start` is set, will wait for a new deployment to start.")
|
|
47
|
+
|
|
48
|
+
attempts = 0
|
|
49
|
+
while True: # do-while
|
|
50
|
+
# Do
|
|
51
|
+
running_deployments = ecs.list_service_deployments(
|
|
52
|
+
cluster=service_ref.cluster,
|
|
53
|
+
service=service_ref.service,
|
|
54
|
+
status=["PENDING", "IN_PROGRESS"],
|
|
55
|
+
)["serviceDeployments"]
|
|
56
|
+
|
|
57
|
+
# While
|
|
58
|
+
if running_deployments:
|
|
59
|
+
logger.debug("Found %d running deployments for service. Exiting loop.", len(running_deployments))
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
if not wait_for_start:
|
|
63
|
+
logger.debug("`wait_for_start` is off, no need to wait for a new deployment to start.")
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
if max_attempts and attempts >= max_attempts:
|
|
67
|
+
logger.debug("Max attempts exceeded while waiting for a new deployment to start.")
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
logger.debug(
|
|
71
|
+
"(%d-th attempt) No running deployments found for service. Start waiting for a new deployment.",
|
|
72
|
+
attempts + 1,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
sleep(polling_interval)
|
|
76
|
+
attempts += 1
|
|
77
|
+
|
|
78
|
+
if not running_deployments:
|
|
79
|
+
msg = "No running deployments found for service."
|
|
80
|
+
raise NoRunningDeploymentError(msg)
|
|
81
|
+
|
|
82
|
+
latest_deployment = max(
|
|
83
|
+
running_deployments,
|
|
84
|
+
key=lambda dep: dep.get(
|
|
85
|
+
"startedAt",
|
|
86
|
+
datetime.min.replace(tzinfo=timezone.utc),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
if len(running_deployments) > 1:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"%d running deployments found for service. Using most recently started deployment: %s",
|
|
92
|
+
len(running_deployments),
|
|
93
|
+
latest_deployment["serviceDeploymentArn"],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return latest_deployment["serviceDeploymentArn"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def wait_for_deployment_complete(
|
|
100
|
+
deployment_arn: str,
|
|
101
|
+
*,
|
|
102
|
+
session: boto3.session.Session | None = None,
|
|
103
|
+
polling_interval: int = 5,
|
|
104
|
+
max_attempts: int | None = None,
|
|
105
|
+
) -> tuple[bool, str]:
|
|
106
|
+
"""Wait for the ECS deployment to complete.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
deployment_arn: The ARN of the deployment to wait for.
|
|
110
|
+
session: The boto3 session to use for the ECS client.
|
|
111
|
+
polling_interval: The interval between any polling attempts, in seconds.
|
|
112
|
+
max_attempts: The maximum number of attempts to wait for the deployment to complete.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
A tuple containing a boolean indicating whether the deployment succeeded and the status of the deployment.
|
|
116
|
+
"""
|
|
117
|
+
session = session or boto3.session.Session()
|
|
118
|
+
ecs = session.client("ecs")
|
|
119
|
+
|
|
120
|
+
attempts = 0
|
|
121
|
+
while (max_attempts is None) or (attempts <= max_attempts):
|
|
122
|
+
latest_deployment = ecs.describe_service_deployments(serviceDeploymentArns=[deployment_arn])[
|
|
123
|
+
"serviceDeployments"
|
|
124
|
+
][0]
|
|
125
|
+
status = latest_deployment["status"]
|
|
126
|
+
if status == "SUCCESSFUL":
|
|
127
|
+
return (True, status)
|
|
128
|
+
|
|
129
|
+
if status in ("PENDING", "IN_PROGRESS"):
|
|
130
|
+
logger.debug(
|
|
131
|
+
"(%d-th attempt) Deployment in progress... (%s)",
|
|
132
|
+
attempts + 1,
|
|
133
|
+
status,
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
sleep(polling_interval)
|
|
139
|
+
attempts += 1
|
|
140
|
+
|
|
141
|
+
return (False, status)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def wait_for_service_stability(
|
|
145
|
+
service_ref: ECSServiceRef,
|
|
146
|
+
*,
|
|
147
|
+
session: boto3.session.Session | None = None,
|
|
148
|
+
polling_interval: int = 5,
|
|
149
|
+
max_attempts: int | None = None,
|
|
150
|
+
) -> bool:
|
|
151
|
+
"""Wait for the ECS service to be stable.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
service_ref: The ECS service reference containing the cluster and service names.
|
|
155
|
+
session: The boto3 session to use for the ECS client.
|
|
156
|
+
polling_interval: The interval between any polling attempts, in seconds.
|
|
157
|
+
max_attempts: The maximum number of attempts to wait for the service to be stable.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A boolean indicating whether the service is stable.
|
|
161
|
+
"""
|
|
162
|
+
session = session or boto3.session.Session()
|
|
163
|
+
ecs = session.client("ecs")
|
|
164
|
+
|
|
165
|
+
# TODO(lasuillard): Likely to be a problem in some cases: https://github.com/boto/botocore/issues/3314
|
|
166
|
+
stability_waiter = ecs.get_waiter("services_stable")
|
|
167
|
+
|
|
168
|
+
attempts = 0
|
|
169
|
+
while (max_attempts is None) or (attempts <= max_attempts):
|
|
170
|
+
logger.debug(
|
|
171
|
+
"(%d-th attempt) Waiting for service %s to be stable...",
|
|
172
|
+
attempts + 1,
|
|
173
|
+
service_ref.service,
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
stability_waiter.wait(
|
|
177
|
+
cluster=service_ref.cluster,
|
|
178
|
+
services=[service_ref.service],
|
|
179
|
+
WaiterConfig={"Delay": polling_interval, "MaxAttempts": 1},
|
|
180
|
+
)
|
|
181
|
+
except botocore.exceptions.WaiterError as err:
|
|
182
|
+
if err.kwargs["reason"] != "Max attempts exceeded":
|
|
183
|
+
raise
|
|
184
|
+
else:
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
sleep(polling_interval)
|
|
188
|
+
attempts += 1
|
|
189
|
+
|
|
190
|
+
return False
|
aws_annoying/utils/downloader.py
CHANGED
|
@@ -42,14 +42,7 @@ class TQDMDownloader(AbstractDownloader):
|
|
|
42
42
|
total_size = int(response.headers.get("content-length", 0))
|
|
43
43
|
with (
|
|
44
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,
|
|
45
|
+
tqdm(desc=url, total=total_size, unit="iB", unit_scale=True, unit_divisor=1_024) as pbar,
|
|
53
46
|
):
|
|
54
47
|
for chunk in response.iter_content(chunk_size=8_192):
|
|
55
48
|
size = f.write(chunk)
|
aws_annoying/utils/ec2.py
CHANGED
|
@@ -26,11 +26,8 @@ def get_instance_id_by_name(name_or_id: str, *, session: boto3.session.Session |
|
|
|
26
26
|
|
|
27
27
|
response = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [name_or_id]}])
|
|
28
28
|
reservations = response["Reservations"]
|
|
29
|
-
if not reservations:
|
|
29
|
+
if not reservations or not reservations[0]["Instances"]:
|
|
30
30
|
return None
|
|
31
31
|
|
|
32
32
|
instances = reservations[0]["Instances"]
|
|
33
|
-
if not instances:
|
|
34
|
-
return None
|
|
35
|
-
|
|
36
33
|
return str(instances[0]["InstanceId"])
|
aws_annoying/utils/timeout.py
CHANGED
|
@@ -2,9 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import signal
|
|
4
4
|
from functools import wraps
|
|
5
|
-
from typing import TYPE_CHECKING, Callable,
|
|
6
|
-
|
|
7
|
-
from pydantic import PositiveInt, validate_call
|
|
5
|
+
from typing import TYPE_CHECKING, Callable, TypeVar, cast
|
|
8
6
|
|
|
9
7
|
from aws_annoying.utils.platform import is_windows
|
|
10
8
|
|
|
@@ -27,8 +25,7 @@ class Timeout:
|
|
|
27
25
|
to do nothing on Windows OS.
|
|
28
26
|
"""
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
def __init__(self, seconds: Optional[PositiveInt]) -> None:
|
|
28
|
+
def __init__(self, seconds: int | None) -> None:
|
|
32
29
|
"""Initialize timeout handler.
|
|
33
30
|
|
|
34
31
|
Args:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-annoying
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -16,7 +16,7 @@ Requires-Dist: tqdm<5,>=4
|
|
|
16
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
|
-
Requires-Dist: mypy
|
|
19
|
+
Requires-Dist: mypy<1.17,>=1.15; extra == 'dev'
|
|
20
20
|
Requires-Dist: ruff<0.12.0,>=0.9.9; extra == 'dev'
|
|
21
21
|
Requires-Dist: types-requests>=2.31.0.6; extra == 'dev'
|
|
22
22
|
Provides-Extra: test
|
|
@@ -2,40 +2,41 @@ aws_annoying/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
2
2
|
aws_annoying/mfa_config.py,sha256=z0GpRhLHEWaaXbECV4Ei4oNM1WCFoEZAxCIPbpY4Ymc,2200
|
|
3
3
|
aws_annoying/variable_loader.py,sha256=N9qPPHG6mzSIIHrWJnJ_FV5kKZxssOaTHoAQEwiDE3s,4569
|
|
4
4
|
aws_annoying/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
aws_annoying/cli/app.py,sha256=
|
|
6
|
-
aws_annoying/cli/load_variables.py,sha256=
|
|
7
|
-
aws_annoying/cli/logging_handler.py,sha256=
|
|
5
|
+
aws_annoying/cli/app.py,sha256=W0FPz0N7okjENwaGpdoWRhcei5Xs-uxSMRaYldaza3M,3295
|
|
6
|
+
aws_annoying/cli/load_variables.py,sha256=n5CIci_uAoGaOZ4EKIKEEfcyMq81Hjy9AFOXq1-Hbh0,4896
|
|
7
|
+
aws_annoying/cli/logging_handler.py,sha256=z2fBHChHiyzQeKtKjykX-JvuWm57gARB3VDmpQSdu6U,1409
|
|
8
8
|
aws_annoying/cli/main.py,sha256=bU4Gxic5_3qrrd8l9eN709-D4o_OHgrdH91FS9Xs8zI,477
|
|
9
9
|
aws_annoying/cli/ecs/__init__.py,sha256=IxfaMXcGU6WTHE_RXj-aitXtSg25j5m3HGTG9O02GjI,125
|
|
10
10
|
aws_annoying/cli/ecs/_app.py,sha256=izD0VL55i7oG-2CtWCV21bSoAeE-DZbxyJ5pi6VXhjU,200
|
|
11
|
-
aws_annoying/cli/ecs/task_definition_lifecycle.py,sha256=
|
|
12
|
-
aws_annoying/cli/ecs/wait_for_deployment.py,sha256=
|
|
11
|
+
aws_annoying/cli/ecs/task_definition_lifecycle.py,sha256=COGYy5o9N6J7tv6aA7qhT-4WsAoxbyPC1UMPtKKrPpg,2704
|
|
12
|
+
aws_annoying/cli/ecs/wait_for_deployment.py,sha256=yBl-KMOya8ZSgpZLcEB0UqtsZfK5ezKPJWNaOdMRRcY,5234
|
|
13
13
|
aws_annoying/cli/mfa/__init__.py,sha256=rbEGhw5lOQZV_XAc3nSbo56JVhsSPpeOgEtiAy9qzEA,50
|
|
14
14
|
aws_annoying/cli/mfa/_app.py,sha256=Ub7gxb6kGF3Ve1ucQSOjHmc4jAu8mxgegcXsIbOzLLQ,189
|
|
15
|
-
aws_annoying/cli/mfa/configure.py,sha256=
|
|
15
|
+
aws_annoying/cli/mfa/configure.py,sha256=Lfmc4VKkUGngbrbxLRrgcEv-vyPM_-1ne_G4U-Vh7Mg,4092
|
|
16
16
|
aws_annoying/cli/session_manager/__init__.py,sha256=FkT6jT6OXduOURN61d-U6hgd-XluQbvuVtKXXiXgSEk,105
|
|
17
17
|
aws_annoying/cli/session_manager/_app.py,sha256=OVOHW0iyKzunvaqLhjoseHw1-WxJ1gGb7QmiyAEezyY,221
|
|
18
18
|
aws_annoying/cli/session_manager/_common.py,sha256=Uj-MF7z8Qntd24Z03xxE-jSKcgrsd8xl41E6db4qCtY,711
|
|
19
|
-
aws_annoying/cli/session_manager/install.py,sha256=
|
|
20
|
-
aws_annoying/cli/session_manager/port_forward.py,sha256=
|
|
21
|
-
aws_annoying/cli/session_manager/start.py,sha256=
|
|
22
|
-
aws_annoying/cli/session_manager/stop.py,sha256=
|
|
23
|
-
aws_annoying/ecs/__init__.py,sha256=
|
|
19
|
+
aws_annoying/cli/session_manager/install.py,sha256=QA88qIu7aYTlBN0tqU-lEN5nMGpshXF3jddBBCZixV8,1550
|
|
20
|
+
aws_annoying/cli/session_manager/port_forward.py,sha256=7tSaGlcoSpJ9G2NYHvcC-PpmVqz-I4B4T8e4Xe45BE8,4495
|
|
21
|
+
aws_annoying/cli/session_manager/start.py,sha256=p4vGUIT4IivJp_Sr7yYljRuYTDHBWULt2Av6AzAjL1I,1536
|
|
22
|
+
aws_annoying/cli/session_manager/stop.py,sha256=SrjGJdodTmcGK1OPyFX61vWzJTqgfsuBRFvScIdYJCU,1641
|
|
23
|
+
aws_annoying/ecs/__init__.py,sha256=G9vVNkbDg-fY3G0Qc7yOGZOnsVp4VtiwzzEgjr6S5Kw,666
|
|
24
|
+
aws_annoying/ecs/check.py,sha256=mxkW8MWCJYng60VKwq3Ws9PEvI1u0aVPdbs4p6SY2j4,1233
|
|
24
25
|
aws_annoying/ecs/common.py,sha256=TvP27SEvdIBnA92Oude-oDCy1SuaYNdtpokkbpZmdzo,139
|
|
25
|
-
aws_annoying/ecs/deployment_waiter.py,sha256=e3xWShvr9piraoEMJdZun2GuffkyvTI76-riECfoe9A,9936
|
|
26
26
|
aws_annoying/ecs/errors.py,sha256=n9j_h1MDUV6IVabKgwbCVAiPZQNJDJ5rVRHA82Je5QQ,429
|
|
27
|
+
aws_annoying/ecs/wait_for.py,sha256=Oj1u1uPp074-Pf-cvVNHxs-S6RLk8lh5EGsVFqi1iH0,6123
|
|
27
28
|
aws_annoying/session_manager/__init__.py,sha256=IENviL3ux2LF7o9xFGYEiqaGw03hxnyNX2btbB1xyEU,318
|
|
28
29
|
aws_annoying/session_manager/errors.py,sha256=YioKlRtZ-GUP0F_ts_ebw7-HYkxe8mTes6HK821Kuiw,353
|
|
29
30
|
aws_annoying/session_manager/session_manager.py,sha256=pUsyJ_w9UzdIfHA2Z8kU6UcZxOqypFXH1rl6pDytdqo,12046
|
|
30
31
|
aws_annoying/session_manager/shortcuts.py,sha256=Yn4wCl43lfttrZ7GbzfGua2jZHe5Fe6ClEy4ikg-Q_s,2143
|
|
31
32
|
aws_annoying/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
32
33
|
aws_annoying/utils/debugger.py,sha256=UFllDCGI2gPtwo1XS5vqw0qyR6bYr7XknmBwSxalKIc,754
|
|
33
|
-
aws_annoying/utils/downloader.py,sha256=
|
|
34
|
-
aws_annoying/utils/ec2.py,sha256=
|
|
34
|
+
aws_annoying/utils/downloader.py,sha256=bAh8Hu55L3F8Fyd1s4NBsyB_L0U-lk7SfKsS00Rp5fA,1660
|
|
35
|
+
aws_annoying/utils/ec2.py,sha256=VsOrnFQDSNplWQ_s-uT11IfiD2mi-74uLbSVoaeXynI,1055
|
|
35
36
|
aws_annoying/utils/platform.py,sha256=TBIzCzYiFj36HmndZedegvFlxPSNtBQyAxzuwelvxNg,985
|
|
36
|
-
aws_annoying/utils/timeout.py,sha256=
|
|
37
|
-
aws_annoying-0.
|
|
38
|
-
aws_annoying-0.
|
|
39
|
-
aws_annoying-0.
|
|
40
|
-
aws_annoying-0.
|
|
41
|
-
aws_annoying-0.
|
|
37
|
+
aws_annoying/utils/timeout.py,sha256=9eCSqhkEp7f7wBoLzkyqfyUKAgY9irwL6LIWBvIvmFI,2394
|
|
38
|
+
aws_annoying-0.8.0.dist-info/METADATA,sha256=PDr5RneXMjRUQglP8tSmNSpBatkPyK3uJilJpm1RISo,3507
|
|
39
|
+
aws_annoying-0.8.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
40
|
+
aws_annoying-0.8.0.dist-info/entry_points.txt,sha256=DcKE5V0WvVJ8wUOHxyUz1yLAJOuuJUgRPlMcQ4O7jEs,66
|
|
41
|
+
aws_annoying-0.8.0.dist-info/licenses/LICENSE,sha256=Q5GkvYijQ2KTQ-QWhv43ilzCno4ZrzrEuATEQZd9rYo,1067
|
|
42
|
+
aws_annoying-0.8.0.dist-info/RECORD,,
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from operator import itemgetter
|
|
5
|
-
from time import sleep
|
|
6
|
-
from typing import TYPE_CHECKING, Optional
|
|
7
|
-
|
|
8
|
-
import boto3
|
|
9
|
-
import botocore.exceptions
|
|
10
|
-
from pydantic import PositiveInt, validate_call
|
|
11
|
-
|
|
12
|
-
from .errors import DeploymentFailedError, NoRunningDeploymentError, ServiceTaskDefinitionAssertionError
|
|
13
|
-
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from .common import ECSServiceRef
|
|
16
|
-
|
|
17
|
-
logger = logging.getLogger(__name__)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class ECSDeploymentWaiter:
|
|
21
|
-
"""ECS service deployment waiter."""
|
|
22
|
-
|
|
23
|
-
def __init__(self, service_ref: ECSServiceRef, *, session: boto3.session.Session | None = None) -> None:
|
|
24
|
-
"""Initialize instance.
|
|
25
|
-
|
|
26
|
-
Args:
|
|
27
|
-
service_ref: Reference to the ECS service.
|
|
28
|
-
session: Boto3 session to use for AWS operations.
|
|
29
|
-
|
|
30
|
-
"""
|
|
31
|
-
self.service_ref = service_ref
|
|
32
|
-
self.session = session or boto3.session.Session()
|
|
33
|
-
|
|
34
|
-
@validate_call
|
|
35
|
-
def wait(
|
|
36
|
-
self,
|
|
37
|
-
*,
|
|
38
|
-
wait_for_start: bool,
|
|
39
|
-
polling_interval: PositiveInt = 5,
|
|
40
|
-
wait_for_stability: bool,
|
|
41
|
-
expected_task_definition: Optional[str] = None,
|
|
42
|
-
) -> None:
|
|
43
|
-
"""Wait for the ECS deployment to complete.
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
wait_for_start: Whether to wait for the deployment to start.
|
|
47
|
-
polling_interval: The interval between any polling attempts, in seconds.
|
|
48
|
-
wait_for_stability: Whether to wait for the service to be stable after the deployment.
|
|
49
|
-
expected_task_definition: The service's task definition expected after deployment.
|
|
50
|
-
"""
|
|
51
|
-
# Find current deployment for the service
|
|
52
|
-
logger.info(
|
|
53
|
-
"Looking up running deployment for service %s",
|
|
54
|
-
self.service_ref.service,
|
|
55
|
-
)
|
|
56
|
-
latest_deployment_arn = self.get_latest_deployment_arn(
|
|
57
|
-
wait_for_start=wait_for_start,
|
|
58
|
-
polling_interval=polling_interval,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
# Polling for the deployment to finish (successfully or unsuccessfully)
|
|
62
|
-
logger.info(
|
|
63
|
-
"Start waiting for deployment %s to finish.",
|
|
64
|
-
latest_deployment_arn,
|
|
65
|
-
)
|
|
66
|
-
ok, status = self.wait_for_deployment_complete(latest_deployment_arn, polling_interval=polling_interval)
|
|
67
|
-
if ok:
|
|
68
|
-
logger.info(
|
|
69
|
-
"Deployment succeeded with status %s",
|
|
70
|
-
status,
|
|
71
|
-
)
|
|
72
|
-
else:
|
|
73
|
-
msg = f"Deployment failed with status: {status}"
|
|
74
|
-
raise DeploymentFailedError(msg)
|
|
75
|
-
|
|
76
|
-
# Wait for the service to be stable
|
|
77
|
-
if wait_for_stability:
|
|
78
|
-
logger.debug(
|
|
79
|
-
"Start waiting for service %s to be stable.",
|
|
80
|
-
self.service_ref.service,
|
|
81
|
-
)
|
|
82
|
-
self.wait_for_service_stability(polling_interval=polling_interval)
|
|
83
|
-
|
|
84
|
-
# Check if the service task definition matches the expected one
|
|
85
|
-
if expected_task_definition:
|
|
86
|
-
logger.info(
|
|
87
|
-
"Checking if the service task definition is the expected one: %s",
|
|
88
|
-
expected_task_definition,
|
|
89
|
-
)
|
|
90
|
-
ok, actual = self.check_service_task_definition_is(expect=expected_task_definition)
|
|
91
|
-
if not ok:
|
|
92
|
-
msg = f"The service task definition is not the expected one; got: {actual!r}"
|
|
93
|
-
raise ServiceTaskDefinitionAssertionError(msg)
|
|
94
|
-
|
|
95
|
-
logger.info("The service task definition matches the expected one.")
|
|
96
|
-
|
|
97
|
-
@validate_call
|
|
98
|
-
def get_latest_deployment_arn(
|
|
99
|
-
self,
|
|
100
|
-
*,
|
|
101
|
-
wait_for_start: bool,
|
|
102
|
-
polling_interval: PositiveInt,
|
|
103
|
-
max_attempts: Optional[PositiveInt] = None,
|
|
104
|
-
) -> str:
|
|
105
|
-
"""Get the most recently started deployment ARN for the service.
|
|
106
|
-
|
|
107
|
-
Args:
|
|
108
|
-
wait_for_start: Whether to wait for the deployment to start.
|
|
109
|
-
polling_interval: The interval between any polling attempts, in seconds.
|
|
110
|
-
max_attempts: The maximum number of attempts to wait for the deployment to start.
|
|
111
|
-
|
|
112
|
-
Raises:
|
|
113
|
-
NoRunningDeploymentError: If no running deployments are found and `wait_for_start` is False.
|
|
114
|
-
|
|
115
|
-
Returns:
|
|
116
|
-
The ARN of the latest deployment for the service.
|
|
117
|
-
"""
|
|
118
|
-
ecs = self.session.client("ecs")
|
|
119
|
-
if wait_for_start:
|
|
120
|
-
logger.warning("`wait_for_start` is set, will wait for a new deployment to start.")
|
|
121
|
-
|
|
122
|
-
attempts = 0
|
|
123
|
-
while True: # do-while
|
|
124
|
-
# Do
|
|
125
|
-
running_deployments = ecs.list_service_deployments(
|
|
126
|
-
cluster=self.service_ref.cluster,
|
|
127
|
-
service=self.service_ref.service,
|
|
128
|
-
status=["PENDING", "IN_PROGRESS"],
|
|
129
|
-
)["serviceDeployments"]
|
|
130
|
-
|
|
131
|
-
# While
|
|
132
|
-
if running_deployments:
|
|
133
|
-
logger.debug("Found %d running deployments for service. Exiting loop.", len(running_deployments))
|
|
134
|
-
break
|
|
135
|
-
|
|
136
|
-
if not wait_for_start:
|
|
137
|
-
logger.debug("`wait_for_start` is off, no need to wait for a new deployment to start.")
|
|
138
|
-
break
|
|
139
|
-
|
|
140
|
-
if max_attempts and attempts >= max_attempts:
|
|
141
|
-
logger.debug("Max attempts exceeded while waiting for a new deployment to start.")
|
|
142
|
-
break
|
|
143
|
-
|
|
144
|
-
logger.debug(
|
|
145
|
-
"(%d-th attempt) No running deployments found for service. Start waiting for a new deployment.",
|
|
146
|
-
attempts + 1,
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
sleep(polling_interval)
|
|
150
|
-
attempts += 1
|
|
151
|
-
|
|
152
|
-
if not running_deployments:
|
|
153
|
-
msg = "No running deployments found for service."
|
|
154
|
-
raise NoRunningDeploymentError(msg)
|
|
155
|
-
|
|
156
|
-
latest_deployment = sorted(running_deployments, key=itemgetter("startedAt"))[-1]
|
|
157
|
-
if len(running_deployments) > 1:
|
|
158
|
-
logger.warning(
|
|
159
|
-
"%d running deployments found for service. Using most recently started deployment: %s",
|
|
160
|
-
len(running_deployments),
|
|
161
|
-
latest_deployment["serviceDeploymentArn"],
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
return latest_deployment["serviceDeploymentArn"]
|
|
165
|
-
|
|
166
|
-
@validate_call
|
|
167
|
-
def wait_for_deployment_complete(
|
|
168
|
-
self,
|
|
169
|
-
deployment_arn: str,
|
|
170
|
-
*,
|
|
171
|
-
polling_interval: PositiveInt,
|
|
172
|
-
max_attempts: Optional[PositiveInt] = None,
|
|
173
|
-
) -> tuple[bool, str]:
|
|
174
|
-
"""Wait for the ECS deployment to complete.
|
|
175
|
-
|
|
176
|
-
Args:
|
|
177
|
-
deployment_arn: The ARN of the deployment to wait for.
|
|
178
|
-
polling_interval: The interval between any polling attempts, in seconds.
|
|
179
|
-
max_attempts: The maximum number of attempts to wait for the deployment to complete.
|
|
180
|
-
|
|
181
|
-
Returns:
|
|
182
|
-
A tuple containing a boolean indicating whether the deployment succeeded and the status of the deployment.
|
|
183
|
-
"""
|
|
184
|
-
ecs = self.session.client("ecs")
|
|
185
|
-
|
|
186
|
-
attempts = 0
|
|
187
|
-
while (max_attempts is None) or (attempts <= max_attempts):
|
|
188
|
-
latest_deployment = ecs.describe_service_deployments(serviceDeploymentArns=[deployment_arn])[
|
|
189
|
-
"serviceDeployments"
|
|
190
|
-
][0]
|
|
191
|
-
status = latest_deployment["status"]
|
|
192
|
-
if status == "SUCCESSFUL":
|
|
193
|
-
return (True, status)
|
|
194
|
-
|
|
195
|
-
if status in ("PENDING", "IN_PROGRESS"):
|
|
196
|
-
logger.debug(
|
|
197
|
-
"(%d-th attempt) Deployment in progress... (%s)",
|
|
198
|
-
attempts + 1,
|
|
199
|
-
status,
|
|
200
|
-
)
|
|
201
|
-
else:
|
|
202
|
-
break
|
|
203
|
-
|
|
204
|
-
sleep(polling_interval)
|
|
205
|
-
attempts += 1
|
|
206
|
-
|
|
207
|
-
return (False, status)
|
|
208
|
-
|
|
209
|
-
@validate_call
|
|
210
|
-
def wait_for_service_stability(
|
|
211
|
-
self,
|
|
212
|
-
*,
|
|
213
|
-
polling_interval: PositiveInt,
|
|
214
|
-
max_attempts: Optional[PositiveInt] = None,
|
|
215
|
-
) -> bool:
|
|
216
|
-
"""Wait for the ECS service to be stable.
|
|
217
|
-
|
|
218
|
-
Args:
|
|
219
|
-
polling_interval: The interval between any polling attempts, in seconds.
|
|
220
|
-
max_attempts: The maximum number of attempts to wait for the service to be stable.
|
|
221
|
-
|
|
222
|
-
Returns:
|
|
223
|
-
A boolean indicating whether the service is stable.
|
|
224
|
-
"""
|
|
225
|
-
ecs = self.session.client("ecs")
|
|
226
|
-
|
|
227
|
-
# TODO(lasuillard): Likely to be a problem in some cases: https://github.com/boto/botocore/issues/3314
|
|
228
|
-
stability_waiter = ecs.get_waiter("services_stable")
|
|
229
|
-
|
|
230
|
-
attempts = 0
|
|
231
|
-
while (max_attempts is None) or (attempts <= max_attempts):
|
|
232
|
-
logger.debug(
|
|
233
|
-
"(%d-th attempt) Waiting for service %s to be stable...",
|
|
234
|
-
attempts + 1,
|
|
235
|
-
self.service_ref.service,
|
|
236
|
-
)
|
|
237
|
-
try:
|
|
238
|
-
stability_waiter.wait(
|
|
239
|
-
cluster=self.service_ref.cluster,
|
|
240
|
-
services=[self.service_ref.service],
|
|
241
|
-
WaiterConfig={"Delay": polling_interval, "MaxAttempts": 1},
|
|
242
|
-
)
|
|
243
|
-
except botocore.exceptions.WaiterError as err:
|
|
244
|
-
if err.kwargs["reason"] != "Max attempts exceeded":
|
|
245
|
-
raise
|
|
246
|
-
else:
|
|
247
|
-
return True
|
|
248
|
-
|
|
249
|
-
sleep(polling_interval)
|
|
250
|
-
attempts += 1
|
|
251
|
-
|
|
252
|
-
return False
|
|
253
|
-
|
|
254
|
-
@validate_call
|
|
255
|
-
def check_service_task_definition_is(self, expect: str) -> tuple[bool, str]:
|
|
256
|
-
"""Check the service's current task definition matches the expected one.
|
|
257
|
-
|
|
258
|
-
Args:
|
|
259
|
-
expect: The ARN of expected task definition.
|
|
260
|
-
|
|
261
|
-
Returns:
|
|
262
|
-
A tuple containing a boolean indicating whether the task definition matches the expected one
|
|
263
|
-
and the current task definition ARN.
|
|
264
|
-
"""
|
|
265
|
-
ecs = self.session.client("ecs")
|
|
266
|
-
|
|
267
|
-
service_detail = ecs.describe_services(cluster=self.service_ref.cluster, services=[self.service_ref.service])[
|
|
268
|
-
"services"
|
|
269
|
-
][0]
|
|
270
|
-
current_task_definition_arn = service_detail["taskDefinition"]
|
|
271
|
-
if current_task_definition_arn != expect:
|
|
272
|
-
return (False, current_task_definition_arn)
|
|
273
|
-
|
|
274
|
-
return (True, current_task_definition_arn)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|