clear-skies-aws 2.0.1__py3-none-any.whl → 2.0.2__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.
- {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/METADATA +1 -1
- clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
- clearskies_aws/__init__.py +15 -0
- clearskies_aws/actions/__init__.py +15 -0
- clearskies_aws/actions/action_aws.py +135 -0
- clearskies_aws/actions/assume_role.py +115 -0
- clearskies_aws/actions/ses.py +203 -0
- clearskies_aws/actions/sns.py +61 -0
- clearskies_aws/actions/sqs.py +81 -0
- clearskies_aws/actions/step_function.py +73 -0
- clearskies_aws/backends/__init__.py +19 -0
- clearskies_aws/backends/backend.py +106 -0
- clearskies_aws/backends/dynamo_db_backend.py +609 -0
- clearskies_aws/backends/dynamo_db_condition_parser.py +325 -0
- clearskies_aws/backends/dynamo_db_parti_ql_backend.py +965 -0
- clearskies_aws/backends/sqs_backend.py +61 -0
- clearskies_aws/configs/__init__.py +0 -0
- clearskies_aws/contexts/__init__.py +23 -0
- clearskies_aws/contexts/cli_web_socket_mock.py +19 -0
- clearskies_aws/contexts/lambda_alb.py +76 -0
- clearskies_aws/contexts/lambda_api_gateway.py +77 -0
- clearskies_aws/contexts/lambda_api_gateway_web_socket.py +57 -0
- clearskies_aws/contexts/lambda_invocation.py +19 -0
- clearskies_aws/contexts/lambda_sns.py +18 -0
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +29 -0
- clearskies_aws/di/__init__.py +6 -0
- clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
- clearskies_aws/di/inject/__init__.py +6 -0
- clearskies_aws/di/inject/boto3.py +15 -0
- clearskies_aws/di/inject/boto3_session.py +13 -0
- clearskies_aws/di/inject/parameter_store.py +15 -0
- clearskies_aws/endpoints/__init__.py +2 -0
- clearskies_aws/endpoints/secrets_manager_rotation.py +195 -0
- clearskies_aws/endpoints/simple_body_routing.py +41 -0
- clearskies_aws/input_outputs/__init__.py +21 -0
- clearskies_aws/input_outputs/cli_web_socket_mock.py +18 -0
- clearskies_aws/input_outputs/lambda_alb.py +53 -0
- clearskies_aws/input_outputs/lambda_api_gateway.py +123 -0
- clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +71 -0
- clearskies_aws/input_outputs/lambda_input_output.py +87 -0
- clearskies_aws/input_outputs/lambda_invocation.py +85 -0
- clearskies_aws/input_outputs/lambda_sns.py +79 -0
- clearskies_aws/input_outputs/lambda_sqs_standard.py +84 -0
- clearskies_aws/mocks/__init__.py +1 -0
- clearskies_aws/mocks/actions/__init__.py +6 -0
- clearskies_aws/mocks/actions/ses.py +34 -0
- clearskies_aws/mocks/actions/sns.py +29 -0
- clearskies_aws/mocks/actions/sqs.py +29 -0
- clearskies_aws/mocks/actions/step_function.py +32 -0
- clearskies_aws/models/__init__.py +0 -0
- clearskies_aws/models/web_socket_connection_model.py +182 -0
- clearskies_aws/secrets/__init__.py +13 -0
- clearskies_aws/secrets/additional_configs/__init__.py +62 -0
- clearskies_aws/secrets/additional_configs/iam_db_auth.py +39 -0
- clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +96 -0
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +80 -0
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +162 -0
- clearskies_aws/secrets/akeyless_with_ssm_cache.py +60 -0
- clearskies_aws/secrets/parameter_store.py +52 -0
- clearskies_aws/secrets/secrets.py +16 -0
- clearskies_aws/secrets/secrets_manager.py +96 -0
- clear_skies_aws-2.0.1.dist-info/RECORD +0 -4
- {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/WHEEL +0 -0
- {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies.exceptions import ClientError
|
|
7
|
+
from clearskies.input_outputs import Headers
|
|
8
|
+
|
|
9
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LambdaInvocation(lambda_input_output.LambdaInputOutput):
|
|
13
|
+
"""Direct Lambda invocation specific input/output handler."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
event: dict[str, Any],
|
|
18
|
+
context: dict[str, Any],
|
|
19
|
+
method: str = "POST",
|
|
20
|
+
url: str = "",
|
|
21
|
+
):
|
|
22
|
+
# Call parent constructor
|
|
23
|
+
super().__init__(event, context)
|
|
24
|
+
|
|
25
|
+
# Direct invocation specific initialization
|
|
26
|
+
self.path = url
|
|
27
|
+
self.request_method = method.upper()
|
|
28
|
+
|
|
29
|
+
# Direct invocations don't have query parameters or path parameters
|
|
30
|
+
self.query_parameters = {}
|
|
31
|
+
|
|
32
|
+
# Direct invocations don't have headers
|
|
33
|
+
self.request_headers = Headers({})
|
|
34
|
+
|
|
35
|
+
def has_body(self) -> bool:
|
|
36
|
+
"""Direct invocations always have a body (the event itself)."""
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def get_body(self) -> str:
|
|
40
|
+
"""Get the entire event as the body."""
|
|
41
|
+
if isinstance(self.event, (dict, list)):
|
|
42
|
+
return json.dumps(self.event)
|
|
43
|
+
return str(self.event)
|
|
44
|
+
|
|
45
|
+
def respond(self, body: Any, status_code: int = 200) -> Any:
|
|
46
|
+
"""Return the response directly for direct invocations."""
|
|
47
|
+
if isinstance(body, bytes):
|
|
48
|
+
return body.decode("utf-8")
|
|
49
|
+
return body
|
|
50
|
+
|
|
51
|
+
def get_client_ip(self) -> str:
|
|
52
|
+
"""Direct invocations don't have client IP information."""
|
|
53
|
+
return "127.0.0.1"
|
|
54
|
+
|
|
55
|
+
def get_protocol(self) -> str:
|
|
56
|
+
"""Direct invocations don't have a protocol."""
|
|
57
|
+
return "lambda"
|
|
58
|
+
|
|
59
|
+
def get_full_path(self) -> str:
|
|
60
|
+
"""Return the configured path."""
|
|
61
|
+
return self.path
|
|
62
|
+
|
|
63
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
64
|
+
"""Provide direct invocation specific context data."""
|
|
65
|
+
return {
|
|
66
|
+
**super().context_specifics(),
|
|
67
|
+
"invocation_type": "direct",
|
|
68
|
+
"function_name": self.context.get("function_name"),
|
|
69
|
+
"function_version": self.context.get("function_version"),
|
|
70
|
+
"request_id": self.context.get("aws_request_id"),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def request_data(self) -> dict[str, Any] | list[Any] | None:
|
|
75
|
+
"""Return the event directly as request data."""
|
|
76
|
+
return self.event
|
|
77
|
+
|
|
78
|
+
def json_body(
|
|
79
|
+
self, required: bool = True, allow_non_json_bodies: bool = False
|
|
80
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
81
|
+
"""Get the event as JSON data."""
|
|
82
|
+
# For direct invocations, the event is already an object, not a JSON string
|
|
83
|
+
if required and not self.event:
|
|
84
|
+
raise ClientError("Request body was not valid JSON")
|
|
85
|
+
return self.event
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies.exceptions import ClientError
|
|
7
|
+
from clearskies.input_outputs import Headers
|
|
8
|
+
|
|
9
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LambdaSns(lambda_input_output.LambdaInputOutput):
|
|
13
|
+
"""SNS specific Lambda input/output handler."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, event: dict, context: dict[str, Any], url: str = "", method: str = "POST"):
|
|
16
|
+
# Call parent constructor
|
|
17
|
+
super().__init__(event, context)
|
|
18
|
+
|
|
19
|
+
# SNS specific initialization
|
|
20
|
+
self.path = url
|
|
21
|
+
self.request_method = method.upper()
|
|
22
|
+
|
|
23
|
+
# SNS events don't have query parameters or path parameters
|
|
24
|
+
self.query_parameters = {}
|
|
25
|
+
|
|
26
|
+
# SNS events don't have headers
|
|
27
|
+
self.request_headers = Headers({})
|
|
28
|
+
|
|
29
|
+
# Extract SNS message from event
|
|
30
|
+
try:
|
|
31
|
+
record = event["Records"][0]["Sns"]["Message"]
|
|
32
|
+
self._record = json.loads(record)
|
|
33
|
+
except (KeyError, IndexError, json.JSONDecodeError) as e:
|
|
34
|
+
raise ClientError(
|
|
35
|
+
"The message from AWS was not a valid SNS event with serialized JSON. "
|
|
36
|
+
"The lambda_sns context for clearskies only accepts serialized JSON."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def respond(self, body: Any, status_code: int = 200) -> dict[str, Any]:
|
|
40
|
+
"""SNS events don't return responses."""
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
def get_body(self) -> str:
|
|
44
|
+
"""Get the SNS message as a JSON string."""
|
|
45
|
+
return json.dumps(self._record) if self._record else ""
|
|
46
|
+
|
|
47
|
+
def has_body(self) -> bool:
|
|
48
|
+
"""Check if SNS message exists."""
|
|
49
|
+
return bool(self._record)
|
|
50
|
+
|
|
51
|
+
def get_client_ip(self) -> str:
|
|
52
|
+
"""SNS events don't have client IP information."""
|
|
53
|
+
return "127.0.0.1"
|
|
54
|
+
|
|
55
|
+
def get_protocol(self) -> str:
|
|
56
|
+
"""SNS events don't have a protocol."""
|
|
57
|
+
return "sns"
|
|
58
|
+
|
|
59
|
+
def get_full_path(self) -> str:
|
|
60
|
+
"""Return the configured path."""
|
|
61
|
+
return self.path
|
|
62
|
+
|
|
63
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
64
|
+
"""Provide SNS specific context data."""
|
|
65
|
+
sns_record = self.event.get("Records", [{}])[0].get("Sns", {})
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
**super().context_specifics(),
|
|
69
|
+
"sns_message_id": sns_record.get("MessageId"),
|
|
70
|
+
"sns_topic_arn": sns_record.get("TopicArn"),
|
|
71
|
+
"sns_subject": sns_record.get("Subject"),
|
|
72
|
+
"sns_timestamp": sns_record.get("Timestamp"),
|
|
73
|
+
"sns_message": self._record,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def request_data(self) -> dict[str, Any] | list[Any] | None:
|
|
78
|
+
"""Return the SNS message data directly."""
|
|
79
|
+
return self._record
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies.configs import AnyDict, String
|
|
7
|
+
from clearskies.exceptions import ClientError
|
|
8
|
+
from clearskies.input_outputs import Headers
|
|
9
|
+
|
|
10
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LambdaSqsStandard(lambda_input_output.LambdaInputOutput):
|
|
14
|
+
"""SQS standard queue specific Lambda input/output handler."""
|
|
15
|
+
|
|
16
|
+
record = AnyDict(default={})
|
|
17
|
+
path = String(default="/")
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, record: str, event: dict[str, Any], context: dict[str, Any], url: str = "", method: str = "POST"
|
|
21
|
+
):
|
|
22
|
+
# Call parent constructor with the full event
|
|
23
|
+
super().__init__(event, context)
|
|
24
|
+
|
|
25
|
+
# Store the individual SQS record
|
|
26
|
+
self.record = json.loads(record)
|
|
27
|
+
print("SQS record:", self.record)
|
|
28
|
+
# SQS specific initialization
|
|
29
|
+
self.path = url if url else "/"
|
|
30
|
+
self.request_method = method.upper() if method else "POST"
|
|
31
|
+
|
|
32
|
+
# SQS events don't have query parameters or path parameters
|
|
33
|
+
self.query_parameters = {}
|
|
34
|
+
|
|
35
|
+
# SQS events don't have headers
|
|
36
|
+
self.request_headers = Headers({})
|
|
37
|
+
|
|
38
|
+
def respond(self, body: Any, status_code: int = 200) -> dict[str, Any]:
|
|
39
|
+
"""SQS events don't return responses."""
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
def get_body(self) -> str:
|
|
43
|
+
"""Get the SQS message body."""
|
|
44
|
+
return json.dumps(self.record)
|
|
45
|
+
|
|
46
|
+
def has_body(self) -> bool:
|
|
47
|
+
"""Check if SQS message has a body."""
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def get_client_ip(self) -> str:
|
|
51
|
+
"""SQS events don't have client IP information."""
|
|
52
|
+
return "127.0.0.1"
|
|
53
|
+
|
|
54
|
+
def get_protocol(self) -> str:
|
|
55
|
+
"""SQS events don't have a protocol."""
|
|
56
|
+
return "sqs"
|
|
57
|
+
|
|
58
|
+
def get_full_path(self) -> str:
|
|
59
|
+
"""Return the configured path."""
|
|
60
|
+
return self.path
|
|
61
|
+
|
|
62
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
63
|
+
"""Provide SQS specific context data."""
|
|
64
|
+
return {
|
|
65
|
+
**super().context_specifics(),
|
|
66
|
+
"sqs_message_id": self.record.get("messageId"),
|
|
67
|
+
"sqs_receipt_handle": self.record.get("receiptHandle"),
|
|
68
|
+
"sqs_source_arn": self.record.get("eventSourceARN"),
|
|
69
|
+
"sqs_sent_timestamp": self.record.get("attributes", {}).get("SentTimestamp"),
|
|
70
|
+
"sqs_approximate_receive_count": self.record.get("attributes", {}).get("ApproximateReceiveCount"),
|
|
71
|
+
"sqs_message_attributes": self.record.get("messageAttributes", {}),
|
|
72
|
+
"sqs_record": self.record,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def request_data(self) -> dict[str, Any]:
|
|
77
|
+
"""Return the SQS message body as parsed JSON."""
|
|
78
|
+
body = self.get_body()
|
|
79
|
+
if not body:
|
|
80
|
+
return {}
|
|
81
|
+
try:
|
|
82
|
+
return json.loads(body)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
raise ClientError("SQS message body was not valid JSON")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import actions
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_ses import SESClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.ses import SES as BaseSES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SES(BaseSES):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseSES, SES)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SESClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if SES.calls is None:
|
|
22
|
+
SES.calls = []
|
|
23
|
+
utcnow = self.di.build("utcnow")
|
|
24
|
+
|
|
25
|
+
SES.calls.append(
|
|
26
|
+
{
|
|
27
|
+
"from": self.sender,
|
|
28
|
+
"to": self._resolve_destination("to", model),
|
|
29
|
+
"cc": self._resolve_destination("cc", model),
|
|
30
|
+
"bcc": self._resolve_destination("bcc", model),
|
|
31
|
+
"subject": self._resolve_subject(model, utcnow),
|
|
32
|
+
"message": self._resolve_message_as_html(model, utcnow),
|
|
33
|
+
}
|
|
34
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_sns import SNSClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.sns import SNS as BaseSNS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SNS(BaseSNS):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseSNS, SNS)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SNSClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if SNS.calls is None:
|
|
22
|
+
SNS.calls = []
|
|
23
|
+
|
|
24
|
+
SNS.calls.append(
|
|
25
|
+
{
|
|
26
|
+
"TopicArn": self.get_topic_arn(model),
|
|
27
|
+
"Message": self.get_message_body(model),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_sqs import SQSClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.sqs import SQS as BaseSQS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQS(BaseSQS):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseSQS, SQS)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SQSClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if SQS.calls is None:
|
|
22
|
+
SQS.calls = []
|
|
23
|
+
|
|
24
|
+
SQS.calls.append(
|
|
25
|
+
{
|
|
26
|
+
"QueueUrl": self.get_queue_url(model),
|
|
27
|
+
"MessageBody": self.get_message_body(model),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_stepfunctions import SFNClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.step_function import StepFunction as BaseStepFunction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StepFunction(BaseStepFunction):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseStepFunction, StepFunction)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SFNClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if StepFunction.calls is None:
|
|
22
|
+
StepFunction.calls = []
|
|
23
|
+
|
|
24
|
+
StepFunction.calls.append(
|
|
25
|
+
{
|
|
26
|
+
"stateMachineArn": self.get_arn(model),
|
|
27
|
+
"input": self.get_message_body(model),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if self.column_to_store_execution_arn:
|
|
32
|
+
model.save({self.column_to_store_execution_arn: "mock_execution_arn"})
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import clearskies
|
|
6
|
+
|
|
7
|
+
import clearskies_aws
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebSocketConnectionModel(clearskies.Model):
|
|
11
|
+
"""
|
|
12
|
+
Help manage message sending to websocket connections.
|
|
13
|
+
|
|
14
|
+
## Working with Websockets
|
|
15
|
+
|
|
16
|
+
This is a partial model class to help send messages to websocket connections in an API gateway.
|
|
17
|
+
With a API Gateway managed websocket, the API gateway assigns an id to every connection, and you
|
|
18
|
+
send messages to the API gateway itself flagged for some client, via its connection id. It
|
|
19
|
+
helps to understand that you don't need to be connected to the websocket itself to send messages
|
|
20
|
+
to the things connected to it. Instead, you just need the necessary permission on the API gateway
|
|
21
|
+
and you need to know the connection id of the client you want to send a message to. For reference,
|
|
22
|
+
the necessary AWS permission is:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
{
|
|
26
|
+
"Version": "2012-10-17",
|
|
27
|
+
"Statement": [
|
|
28
|
+
{
|
|
29
|
+
"Effect": "Allow",
|
|
30
|
+
"Action": ["execute-api:ManageConnections"],
|
|
31
|
+
"Resource": "arn:aws:execute-api:${aws_region_name}:${aws_account_id}:${api_gateway_id}/{stage}/*",
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A simple flow that reproduces a pub/sub approach is:
|
|
38
|
+
|
|
39
|
+
1. Client connects to the API gateway via a websocket, and the backend records the connection id
|
|
40
|
+
in a backend somewhere
|
|
41
|
+
2. Client sends a message through the websocket to "register"/"subscribe" for some resource, and
|
|
42
|
+
the backend service updates the record for the connection to record what resource it is
|
|
43
|
+
subscribed to
|
|
44
|
+
3. If a server needs to send a message to everyone subscribed to a resource, it queries the backends
|
|
45
|
+
for all records connected to the resource in question and sends a message to their connection ids
|
|
46
|
+
through the backend.
|
|
47
|
+
4. If a client needs to send a message to everyone subscribed to a resource, it sends a message
|
|
48
|
+
through the websocket to some backend service which then passes the message along to the appropriate
|
|
49
|
+
connections, just as in #3 above.
|
|
50
|
+
|
|
51
|
+
Most examples with Websockets and API Gateway use dynamodb for storage. You can, of course, use whatever
|
|
52
|
+
backend you want. Still, below is an example pub/sub application to demonstrate how to build a basic
|
|
53
|
+
websocket app:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
import clearskies
|
|
57
|
+
import clearskies_aws
|
|
58
|
+
|
|
59
|
+
#####################
|
|
60
|
+
## Our model class ##
|
|
61
|
+
#####################
|
|
62
|
+
|
|
63
|
+
class Client(clearskies_aws.models.WebSocketConnectionModel):
|
|
64
|
+
backend = clearskies_aws.backends.DynamoDbBackend()
|
|
65
|
+
|
|
66
|
+
# the base WebSocketConnectionModel class defines a string
|
|
67
|
+
# column called `connection_id` and sets it as the id column,
|
|
68
|
+
# so those are already set. We just need any additional columns
|
|
69
|
+
resource_id = clearskies.columns.String()
|
|
70
|
+
|
|
71
|
+
###########################
|
|
72
|
+
## Our application logic ##
|
|
73
|
+
###########################
|
|
74
|
+
|
|
75
|
+
def on_connect(clients, connection_id):
|
|
76
|
+
clients.create({"connection_id": connection_id})
|
|
77
|
+
|
|
78
|
+
def on_subscribe(clients, connection_id, request_data):
|
|
79
|
+
client = clients.find(f"connection_id={connection_id}")
|
|
80
|
+
if client.exists:
|
|
81
|
+
# we blindly save the request id, which makes it user-generated. This also
|
|
82
|
+
# allows the client to "unsubscribe" by sending up a blank resource
|
|
83
|
+
client.save({"resource_id": request_data["resource_id"])
|
|
84
|
+
|
|
85
|
+
def on_publish(clients, connection_id, request_data):
|
|
86
|
+
my_client = clients.find(f"connection_id={connection_id}")
|
|
87
|
+
message = request_data.get("message")
|
|
88
|
+
|
|
89
|
+
# The problem with our standard input validation is that we can't return a response
|
|
90
|
+
# to the client with an error message. This is not a transactional system. Instead,
|
|
91
|
+
# we would have to send the client a new message with the error in it, which is not
|
|
92
|
+
# something the clearskies endpoints are designed for.
|
|
93
|
+
if not message or not my_client.resource_id:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
for client in clients.where(f"resource_id={my_client.resource_id}").paginate_all():
|
|
97
|
+
if client.connection_id == my_client.connection_id:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# the send function is provided by clearskies_aws.models.WebSocketConnectionModel
|
|
101
|
+
client.send({"message": message})
|
|
102
|
+
|
|
103
|
+
def on_disconnect(clients, connection_id):
|
|
104
|
+
clients.find(f"connection_id={connection_id}").delete(except_if_not_exists=False)
|
|
105
|
+
|
|
106
|
+
######################################
|
|
107
|
+
## Wiring it all up with clearskies ##
|
|
108
|
+
######################################
|
|
109
|
+
|
|
110
|
+
# We're going to build one application, even though each action gets it's own lambda.
|
|
111
|
+
# The URLs aren't used for routing, but simply to allow us to select which function
|
|
112
|
+
# is associated with each lambda. Actual routing still happens in the API Gateway
|
|
113
|
+
websocket_application = clearskies_aws.contexts.LambdaApiGatewayWebSocket(
|
|
114
|
+
clearskies.EndpointGroup([
|
|
115
|
+
clearskies.endpoints.Callable(
|
|
116
|
+
on_connect,
|
|
117
|
+
url="on_connect",
|
|
118
|
+
),
|
|
119
|
+
clearskies.endpoints.Callable(
|
|
120
|
+
on_subscribe,
|
|
121
|
+
url="on_subscribe",
|
|
122
|
+
),
|
|
123
|
+
clearskies.endpoints.Callable(
|
|
124
|
+
on_publish,
|
|
125
|
+
url="on_publish",
|
|
126
|
+
),
|
|
127
|
+
clearskies.endpoints.Callable(
|
|
128
|
+
on_disconnect,
|
|
129
|
+
url="on_disconnect",
|
|
130
|
+
),
|
|
131
|
+
]),
|
|
132
|
+
classes=[Client],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
################################
|
|
136
|
+
## The actual lambda handlers ##
|
|
137
|
+
################################
|
|
138
|
+
|
|
139
|
+
def on_connect_handler(event, context):
|
|
140
|
+
return websocket_application(url="on_connect")
|
|
141
|
+
|
|
142
|
+
def on_subscribe_handler(event, context):
|
|
143
|
+
return websocket_application(url="on_subscribe")
|
|
144
|
+
|
|
145
|
+
def on_publish_handler(event, context):
|
|
146
|
+
return websocket_application(url="on_publish")
|
|
147
|
+
|
|
148
|
+
def on_disconnect_handler(event, context):
|
|
149
|
+
return websocket_application(url="on_disconnect")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
id_column_name = "connection_id"
|
|
155
|
+
|
|
156
|
+
boto3 = clearskies_aws.di.inject.Boto3()
|
|
157
|
+
connection_id = clearskies.columns.String()
|
|
158
|
+
input_output = clearskies.di.inject.InputOutput()
|
|
159
|
+
|
|
160
|
+
def send(self, message):
|
|
161
|
+
if not self:
|
|
162
|
+
raise ValueError("Cannot send message to non-existent connection.")
|
|
163
|
+
if not self.connection_id:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"Hmmm... I couldn't find the connection id for the {self.__class__.__name__}. I'm picky about id column names. Can you please make sure I have a column called connection_id and that it contains the connection id?"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
domain = self.input_output.context_specifics()["domain"]
|
|
169
|
+
stage = self.input_output.context_specifics()["stage"]
|
|
170
|
+
# only include the stage if we're using the default AWS domain - not with a custom domain
|
|
171
|
+
if ".amazonaws.com" in domain:
|
|
172
|
+
endpoint_url = f"https://{domain}/{stage}"
|
|
173
|
+
else:
|
|
174
|
+
endpoint_url = f"https://{domain}"
|
|
175
|
+
api_gateway = self.boto3.client("apigatewaymanagementapi", endpoint_url=endpoint_url)
|
|
176
|
+
|
|
177
|
+
bytes_message = json.dumps(message).encode("utf-8")
|
|
178
|
+
try:
|
|
179
|
+
response = api_gateway.post_to_connection(Data=bytes_message, ConnectionId=self.connection_id)
|
|
180
|
+
except api_gateway.exceptions.GoneException:
|
|
181
|
+
self.delete()
|
|
182
|
+
return response
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from clearskies_aws.secrets import additional_configs
|
|
2
|
+
from clearskies_aws.secrets.akeyless_with_ssm_cache import AkeylessWithSsmCache
|
|
3
|
+
from clearskies_aws.secrets.parameter_store import ParameterStore
|
|
4
|
+
from clearskies_aws.secrets.secrets import Secrets
|
|
5
|
+
from clearskies_aws.secrets.secrets_manager import SecretsManager
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Secrets",
|
|
9
|
+
"ParameterStore",
|
|
10
|
+
"SecretsManager",
|
|
11
|
+
"AkeylessWithSsmCache",
|
|
12
|
+
"additional_configs",
|
|
13
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from .iam_db_auth import IAMDBAuth
|
|
2
|
+
from .iam_db_auth_with_ssm import IAMDBAuthWithSSM
|
|
3
|
+
from .mysql_connection_dynamic_producer_via_ssh_cert_bastion import MySQLConnectionDynamicProducerViaSSHCertBastion
|
|
4
|
+
from .mysql_connection_dynamic_producer_via_ssm_bastion import MySQLConnectionDynamicProducerViaSSMBastion
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def mysql_connection_dynamic_producer_via_ssh_cert_bastion(
|
|
8
|
+
producer_name=None,
|
|
9
|
+
bastion_host=None,
|
|
10
|
+
bastion_name=None,
|
|
11
|
+
bastion_region=None,
|
|
12
|
+
bastion_username=None,
|
|
13
|
+
public_key_file_path=None,
|
|
14
|
+
cert_issuer_name=None,
|
|
15
|
+
database_host=None,
|
|
16
|
+
database_name=None,
|
|
17
|
+
local_proxy_port=None,
|
|
18
|
+
):
|
|
19
|
+
return MySQLConnectionDynamicProducerViaSSHCertBastion(
|
|
20
|
+
producer_name=producer_name,
|
|
21
|
+
bastion_host=bastion_host,
|
|
22
|
+
bastion_name=bastion_name,
|
|
23
|
+
bastion_region=bastion_region,
|
|
24
|
+
bastion_username=bastion_username,
|
|
25
|
+
cert_issuer_name=cert_issuer_name,
|
|
26
|
+
public_key_file_path=public_key_file_path,
|
|
27
|
+
database_host=database_host,
|
|
28
|
+
database_name=database_name,
|
|
29
|
+
local_proxy_port=local_proxy_port,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def mysql_connection_dynamic_producer_via_ssm_bastion(
|
|
34
|
+
producer_name=None,
|
|
35
|
+
bastion_instance_id=None,
|
|
36
|
+
bastion_name=None,
|
|
37
|
+
bastion_region=None,
|
|
38
|
+
bastion_username=None,
|
|
39
|
+
public_key_file_path=None,
|
|
40
|
+
database_host=None,
|
|
41
|
+
database_name=None,
|
|
42
|
+
local_proxy_port=None,
|
|
43
|
+
):
|
|
44
|
+
return MySQLConnectionDynamicProducerViaSSMBastion(
|
|
45
|
+
producer_name=producer_name,
|
|
46
|
+
bastion_instance_id=bastion_instance_id,
|
|
47
|
+
bastion_name=bastion_name,
|
|
48
|
+
bastion_region=bastion_region,
|
|
49
|
+
bastion_username=bastion_username,
|
|
50
|
+
public_key_file_path=public_key_file_path,
|
|
51
|
+
database_host=database_host,
|
|
52
|
+
database_name=database_name,
|
|
53
|
+
local_proxy_port=local_proxy_port,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def iam_db_auth():
|
|
58
|
+
return IAMDBAuth()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def iam_db_auth_with_ssm():
|
|
62
|
+
return IAMDBAuthWithSSM()
|