Skip to content

Commit e238d3e

Browse files
authored
✨AWS-library: Added interface to AWS SSM (#6032)
1 parent b0a28d3 commit e238d3e

File tree

24 files changed

+1010
-36
lines changed

24 files changed

+1010
-36
lines changed

.coveragerc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@ exclude_lines =
2626
# Don't complain about abstract methods, they aren't run:
2727
@(abc\.)?abstract(((class|static)?method)|property)
2828

29+
# Don't complain about type checking
30+
if TYPE_CHECKING:
31+
2932
ignore_errors = True
3033
show_missing = True

Makefile

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,8 @@ __check_defined = \
124124
.PHONY: help
125125

126126
help: ## help on rule's targets
127-
ifeq ($(IS_WIN),)
128-
@awk --posix 'BEGIN {FS = ":.*?## "} /^[[:alpha:][:space:]_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
129-
else
130-
@awk --posix 'BEGIN {FS = ":.*?## "} /^[[:alpha:][:space:]_-]+:.*?## / {printf "%-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
131-
endif
127+
@awk 'BEGIN {FS = ":.*?## "}; /^[^.[:space:]].*?:.*?## / {if ($$1 != "help" && NF == 2) {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}}' $(MAKEFILE_LIST)
128+
132129

133130
test_python_version: ## Check Python version, throw error if compilation would fail with the installed version
134131
python ./scripts/test_python_version.py
@@ -459,14 +456,13 @@ push-version: tag-version
459456
.check-uv-installed:
460457
@echo "Checking if 'uv' is installed..."
461458
@if ! command -v uv >/dev/null 2>&1; then \
462-
printf "\033[31mError: 'uv' is not installed.\033[0m\n"; \
463-
printf "To install 'uv', run the following command:\n"; \
464-
printf "\033[34mcurl -LsSf https://astral.sh/uv/install.sh | sh\033[0m\n"; \
465-
exit 1; \
459+
curl -LsSf https://astral.sh/uv/install.sh | sh; \
466460
else \
467461
printf "\033[32m'uv' is installed. Version: \033[0m"; \
468462
uv --version; \
469463
fi
464+
# upgrading uv
465+
-@uv self --quiet update
470466

471467

472468
.venv: .check-uv-installed

packages/aws-library/requirements/_base.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
aioboto3
1010
aiocache
1111
pydantic[email]
12-
types-aiobotocore[ec2,s3]
12+
types-aiobotocore[ec2,s3,ssm]
1313
sh

packages/aws-library/requirements/_base.txt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,13 @@ typer==0.12.3
184184
# -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
185185
# -r requirements/../../../packages/settings-library/requirements/_base.in
186186
# faststream
187-
types-aiobotocore==2.12.3
187+
types-aiobotocore==2.13.0
188188
# via -r requirements/_base.in
189-
types-aiobotocore-ec2==2.12.3
189+
types-aiobotocore-ec2==2.13.0
190190
# via types-aiobotocore
191-
types-aiobotocore-s3==2.12.3
191+
types-aiobotocore-s3==2.13.0
192+
# via types-aiobotocore
193+
types-aiobotocore-ssm==2.13.0
192194
# via types-aiobotocore
193195
types-awscrt==0.20.9
194196
# via botocore-stubs
@@ -205,6 +207,7 @@ typing-extensions==4.11.0
205207
# types-aiobotocore
206208
# types-aiobotocore-ec2
207209
# types-aiobotocore-s3
210+
# types-aiobotocore-ssm
208211
urllib3==2.2.1
209212
# via
210213
# -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt

packages/aws-library/src/aws_library/ec2/client.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import contextlib
22
import logging
3-
from collections.abc import Iterable
3+
from collections.abc import Iterable, Sequence
44
from dataclasses import dataclass
55
from typing import cast
66

@@ -22,6 +22,7 @@
2222
EC2TooManyInstancesError,
2323
)
2424
from .models import (
25+
AWSTagKey,
2526
EC2InstanceConfig,
2627
EC2InstanceData,
2728
EC2InstanceType,
@@ -274,6 +275,24 @@ async def get_instances(
274275
)
275276
return all_instances
276277

278+
async def stop_instances(self, instance_datas: Iterable[EC2InstanceData]) -> None:
279+
try:
280+
with log_context(
281+
_logger,
282+
logging.INFO,
283+
msg=f"stopping instances {[i.id for i in instance_datas]}",
284+
):
285+
await self.client.stop_instances(
286+
InstanceIds=[i.id for i in instance_datas]
287+
)
288+
except botocore.exceptions.ClientError as exc:
289+
if (
290+
exc.response.get("Error", {}).get("Code", "")
291+
== "InvalidInstanceID.NotFound"
292+
):
293+
raise EC2InstanceNotFoundError from exc
294+
raise # pragma: no cover
295+
277296
async def terminate_instances(
278297
self, instance_datas: Iterable[EC2InstanceData]
279298
) -> None:
@@ -295,7 +314,7 @@ async def terminate_instances(
295314
raise # pragma: no cover
296315

297316
async def set_instances_tags(
298-
self, instances: list[EC2InstanceData], *, tags: EC2Tags
317+
self, instances: Sequence[EC2InstanceData], *, tags: EC2Tags
299318
) -> None:
300319
try:
301320
with log_context(
@@ -314,3 +333,21 @@ async def set_instances_tags(
314333
if exc.response.get("Error", {}).get("Code", "") == "InvalidID":
315334
raise EC2InstanceNotFoundError from exc
316335
raise # pragma: no cover
336+
337+
async def remove_instances_tags(
338+
self, instances: Sequence[EC2InstanceData], *, tag_keys: Iterable[AWSTagKey]
339+
) -> None:
340+
try:
341+
with log_context(
342+
_logger,
343+
logging.DEBUG,
344+
msg=f"removing {tag_keys=} of instances '[{[i.id for i in instances]}]'",
345+
):
346+
await self.client.delete_tags(
347+
Resources=[i.id for i in instances],
348+
Tags=[{"Key": tag_key} for tag_key in tag_keys],
349+
)
350+
except botocore.exceptions.ClientError as exc:
351+
if exc.response.get("Error", {}).get("Code", "") == "InvalidID":
352+
raise EC2InstanceNotFoundError from exc
353+
raise # pragma: no cover

packages/aws-library/src/aws_library/ec2/models.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
BaseModel,
1111
ByteSize,
1212
ConstrainedStr,
13-
Extra,
1413
Field,
1514
NonNegativeFloat,
1615
validator,
@@ -142,7 +141,6 @@ class EC2InstanceBootSpecific(BaseModel):
142141
)
143142

144143
class Config:
145-
extra = Extra.forbid
146144
schema_extra: ClassVar[dict[str, Any]] = {
147145
"examples": [
148146
{
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from ._client import SimcoreSSMAPI
2+
from ._errors import (
3+
SSMAccessError,
4+
SSMCommandExecutionError,
5+
SSMInvalidCommandError,
6+
SSMNotConnectedError,
7+
SSMRuntimeError,
8+
SSMSendCommandInstancesNotReadyError,
9+
)
10+
11+
__all__: tuple[str, ...] = (
12+
"SimcoreSSMAPI",
13+
"SSMAccessError",
14+
"SSMNotConnectedError",
15+
"SSMRuntimeError",
16+
"SSMSendCommandInstancesNotReadyError",
17+
"SSMInvalidCommandError",
18+
"SSMCommandExecutionError",
19+
)
20+
21+
# nopycln: file
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import contextlib
2+
import logging
3+
from collections.abc import Sequence
4+
from dataclasses import dataclass
5+
from typing import Final, cast
6+
7+
import aioboto3
8+
import botocore
9+
import botocore.exceptions
10+
from aiobotocore.session import ClientCreatorContext
11+
from servicelib.logging_utils import log_decorator
12+
from settings_library.ssm import SSMSettings
13+
from types_aiobotocore_ssm import SSMClient
14+
from types_aiobotocore_ssm.literals import CommandStatusType
15+
16+
from ._error_handler import ssm_exception_handler
17+
from ._errors import SSMCommandExecutionError
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
_AWS_WAIT_MAX_DELAY: Final[int] = 5
22+
_AWS_WAIT_NUM_RETRIES: Final[int] = 3
23+
24+
25+
@dataclass(frozen=True)
26+
class SSMCommand:
27+
name: str
28+
command_id: str
29+
instance_ids: Sequence[str]
30+
status: CommandStatusType
31+
message: str | None = None
32+
33+
34+
@dataclass(frozen=True)
35+
class SimcoreSSMAPI:
36+
_client: SSMClient
37+
_session: aioboto3.Session
38+
_exit_stack: contextlib.AsyncExitStack
39+
40+
@classmethod
41+
async def create(cls, settings: SSMSettings) -> "SimcoreSSMAPI":
42+
session = aioboto3.Session()
43+
session_client = session.client(
44+
"ssm",
45+
endpoint_url=f"{settings.SSM_ENDPOINT}",
46+
aws_access_key_id=settings.SSM_ACCESS_KEY_ID.get_secret_value(),
47+
aws_secret_access_key=settings.SSM_SECRET_ACCESS_KEY.get_secret_value(),
48+
region_name=settings.SSM_REGION_NAME,
49+
)
50+
assert isinstance(session_client, ClientCreatorContext) # nosec
51+
exit_stack = contextlib.AsyncExitStack()
52+
ec2_client = cast(
53+
SSMClient, await exit_stack.enter_async_context(session_client)
54+
)
55+
return cls(ec2_client, session, exit_stack)
56+
57+
async def close(self) -> None:
58+
await self._exit_stack.aclose()
59+
60+
async def ping(self) -> bool:
61+
try:
62+
await self._client.list_commands(MaxResults=1)
63+
return True
64+
except Exception: # pylint: disable=broad-except
65+
return False
66+
67+
# a function to send a command via ssm
68+
@log_decorator(_logger, logging.DEBUG)
69+
@ssm_exception_handler(_logger)
70+
async def send_command(
71+
self, instance_ids: Sequence[str], *, command: str, command_name: str
72+
) -> SSMCommand:
73+
# NOTE: using Targets instead of instances as this is limited to 50 instances
74+
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.send_command
75+
response = await self._client.send_command(
76+
Targets=[{"Key": "InstanceIds", "Values": instance_ids}],
77+
DocumentName="AWS-RunShellScript",
78+
Comment=command_name,
79+
Parameters={"commands": [command]},
80+
)
81+
assert response["Command"] # nosec
82+
assert "Comment" in response["Command"] # nosec
83+
assert "CommandId" in response["Command"] # nosec
84+
assert "Status" in response["Command"] # nosec
85+
86+
return SSMCommand(
87+
name=response["Command"]["Comment"],
88+
command_id=response["Command"]["CommandId"],
89+
status=response["Command"]["Status"],
90+
instance_ids=instance_ids,
91+
)
92+
93+
@log_decorator(_logger, logging.DEBUG)
94+
@ssm_exception_handler(_logger)
95+
async def get_command(self, instance_id: str, *, command_id: str) -> SSMCommand:
96+
97+
response = await self._client.get_command_invocation(
98+
CommandId=command_id, InstanceId=instance_id
99+
)
100+
101+
return SSMCommand(
102+
name=response["Comment"],
103+
command_id=response["CommandId"],
104+
instance_ids=[response["InstanceId"]],
105+
status=response["Status"] if response["Status"] != "Delayed" else "Pending",
106+
message=response["StatusDetails"],
107+
)
108+
109+
@log_decorator(_logger, logging.DEBUG)
110+
@ssm_exception_handler(_logger)
111+
async def is_instance_connected_to_ssm_server(self, instance_id: str) -> bool:
112+
response = await self._client.describe_instance_information(
113+
InstanceInformationFilterList=[
114+
{
115+
"key": "InstanceIds",
116+
"valueSet": [
117+
instance_id,
118+
],
119+
}
120+
],
121+
)
122+
assert response["InstanceInformationList"] # nosec
123+
assert len(response["InstanceInformationList"]) == 1 # nosec
124+
assert "PingStatus" in response["InstanceInformationList"][0] # nosec
125+
return bool(response["InstanceInformationList"][0]["PingStatus"] == "Online")
126+
127+
@log_decorator(_logger, logging.DEBUG)
128+
@ssm_exception_handler(_logger)
129+
async def wait_for_has_instance_completed_cloud_init(
130+
self, instance_id: str
131+
) -> bool:
132+
cloud_init_status_command = await self.send_command(
133+
(instance_id,),
134+
command="cloud-init status",
135+
command_name="cloud-init status",
136+
)
137+
# wait for command to complete
138+
waiter = self._client.get_waiter( # pylint: disable=assignment-from-no-return
139+
"command_executed"
140+
)
141+
try:
142+
await waiter.wait(
143+
CommandId=cloud_init_status_command.command_id,
144+
InstanceId=instance_id,
145+
WaiterConfig={
146+
"Delay": _AWS_WAIT_MAX_DELAY,
147+
"MaxAttempts": _AWS_WAIT_NUM_RETRIES,
148+
},
149+
)
150+
except botocore.exceptions.WaiterError as exc:
151+
msg = f"Timed-out waiting for {instance_id} to complete cloud-init"
152+
raise SSMCommandExecutionError(details=msg) from exc
153+
response = await self._client.get_command_invocation(
154+
CommandId=cloud_init_status_command.command_id, InstanceId=instance_id
155+
)
156+
if response["Status"] != "Success":
157+
raise SSMCommandExecutionError(details=response["StatusDetails"])
158+
# check if cloud-init is done
159+
return bool("status: done" in response["StandardOutputContent"])

0 commit comments

Comments
 (0)