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 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 = Console(soft_wrap=True, emoji=False)
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 DeploymentFailedError, ECSDeploymentWaiter, ECSServiceRef
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
- waiter.wait(
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": "ℹ️", # noqa: RUF001
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
- update_credentials(
106
- aws_credentials,
107
- mfa_profile, # type: ignore[arg-type]
108
- access_key=credentials["AccessKeyId"],
109
- secret_key=credentials["SecretAccessKey"],
110
- session_token=credentials["SessionToken"],
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
- mfa_config.save_ini_file(aws_config, aws_config_section)
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
- yes: bool = typer.Option( # noqa: FBT001
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
- session_manager.install(confirm=yes, downloader=TQDMDownloader())
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( # noqa: FBT001
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.info("Instance with name '%s' not found.", through)
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
- proc = subprocess.Popen( # noqa: S603
115
- command,
116
- stdout=stdout,
117
- stderr=subprocess.STDOUT,
118
- text=True,
119
- close_fds=False, # FD inherited from parent process
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
- proc.pid,
131
+ pid,
124
132
  log_file.absolute(),
125
133
  )
126
134
 
127
135
  # Write the PID to the file
128
- pid_file.write_text(str(proc.pid))
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
- os.execvp(command[0], command) # noqa: S606
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( # noqa: FBT001
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
- os.kill(pid, signal.SIGTERM)
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
- pid_file.unlink()
55
+ if not dry_run:
56
+ pid_file.unlink()
51
57
 
52
58
  logger.info("Terminated the session successfully.")
@@ -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
@@ -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"])
@@ -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, Optional, TypeVar, cast
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
- @validate_call
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.6.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~=1.15.0; extra == 'dev'
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=6opsAfIFGPnUSK68s7sEnFzLhipnevfP2cDJ0yj7Lh0,2366
6
- aws_annoying/cli/load_variables.py,sha256=83RGt5eA_TJtza3kyLXoSM_A3lfQs6TNC2TCrWYia_I,5032
7
- aws_annoying/cli/logging_handler.py,sha256=JPsZePV3YH9e1nLv0Q3fqSeXSEfZ5cGJo6eLK2F4oxE,1426
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=W7zr8zEPjjHgss9Tnb3QMcJ_45Yb9SJ5D1B315vb9kc,2803
12
- aws_annoying/cli/ecs/wait_for_deployment.py,sha256=0lzl5t0TOvJJeMxIA6if2TbG7bJFf6JulhPHZqNXjFM,3169
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=s2SCz7gzvIE9mJM7w95JPwoGhuQrgkFhjUd6qjD169k,3957
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=VIk6313jUf6THwvs06AOUQwhTjzCGCcvDXMxyz0BcgE,1477
20
- aws_annoying/cli/session_manager/port_forward.py,sha256=upsUsd7MkUfaAP3o0ZK6N-SkOBnr1ogwqYozG4TDTzY,4331
21
- aws_annoying/cli/session_manager/start.py,sha256=1Q-WFvbQkMgVntwIrM2HfpUKmVL38aFrjJjG3aA0fzU,1447
22
- aws_annoying/cli/session_manager/stop.py,sha256=QjjOmmhZr_0IInhOyHvSlodk7Nx4qu9EtrRw4kl8Hks,1535
23
- aws_annoying/ecs/__init__.py,sha256=Bohwe4-jxF1cxQmErCXl0l5ma08HpWmV2PqdAS-gf4w,432
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=aB5RzT-LpbFX24-2HXlAkdgVowc4TR9FWT_K8WwZ1BE,1923
34
- aws_annoying/utils/ec2.py,sha256=RjEA5eO53c-H6VE6cZBeB1RJo1rxpDQFhbZTYdANbs8,1062
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=PjfFtiLALh7lQvchtMYOfjTNrfuZwCaeaPKW16EpM5c,2483
37
- aws_annoying-0.6.0.dist-info/METADATA,sha256=68sKE-Atya0ibgT1ljJsaKHpnKXHDq-x7EdUy0yRIqI,3503
38
- aws_annoying-0.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
39
- aws_annoying-0.6.0.dist-info/entry_points.txt,sha256=DcKE5V0WvVJ8wUOHxyUz1yLAJOuuJUgRPlMcQ4O7jEs,66
40
- aws_annoying-0.6.0.dist-info/licenses/LICENSE,sha256=Q5GkvYijQ2KTQ-QWhv43ilzCno4ZrzrEuATEQZd9rYo,1067
41
- aws_annoying-0.6.0.dist-info/RECORD,,
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)