clear-skies-aws 2.0.1__py3-none-any.whl → 2.0.3__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.
Files changed (64) hide show
  1. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.3.dist-info}/METADATA +2 -2
  2. clear_skies_aws-2.0.3.dist-info/RECORD +63 -0
  3. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.3.dist-info}/WHEEL +1 -1
  4. clearskies_aws/__init__.py +27 -0
  5. clearskies_aws/actions/__init__.py +15 -0
  6. clearskies_aws/actions/action_aws.py +135 -0
  7. clearskies_aws/actions/assume_role.py +115 -0
  8. clearskies_aws/actions/ses.py +203 -0
  9. clearskies_aws/actions/sns.py +61 -0
  10. clearskies_aws/actions/sqs.py +81 -0
  11. clearskies_aws/actions/step_function.py +73 -0
  12. clearskies_aws/backends/__init__.py +19 -0
  13. clearskies_aws/backends/backend.py +106 -0
  14. clearskies_aws/backends/dynamo_db_backend.py +609 -0
  15. clearskies_aws/backends/dynamo_db_condition_parser.py +325 -0
  16. clearskies_aws/backends/dynamo_db_parti_ql_backend.py +965 -0
  17. clearskies_aws/backends/sqs_backend.py +61 -0
  18. clearskies_aws/configs/__init__.py +0 -0
  19. clearskies_aws/contexts/__init__.py +23 -0
  20. clearskies_aws/contexts/cli_web_socket_mock.py +20 -0
  21. clearskies_aws/contexts/lambda_alb.py +81 -0
  22. clearskies_aws/contexts/lambda_api_gateway.py +81 -0
  23. clearskies_aws/contexts/lambda_api_gateway_web_socket.py +79 -0
  24. clearskies_aws/contexts/lambda_invoke.py +138 -0
  25. clearskies_aws/contexts/lambda_sns.py +124 -0
  26. clearskies_aws/contexts/lambda_sqs_standard.py +139 -0
  27. clearskies_aws/di/__init__.py +6 -0
  28. clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
  29. clearskies_aws/di/inject/__init__.py +6 -0
  30. clearskies_aws/di/inject/boto3.py +15 -0
  31. clearskies_aws/di/inject/boto3_session.py +13 -0
  32. clearskies_aws/di/inject/parameter_store.py +15 -0
  33. clearskies_aws/endpoints/__init__.py +1 -0
  34. clearskies_aws/endpoints/secrets_manager_rotation.py +194 -0
  35. clearskies_aws/endpoints/simple_body_routing.py +41 -0
  36. clearskies_aws/input_outputs/__init__.py +21 -0
  37. clearskies_aws/input_outputs/cli_web_socket_mock.py +20 -0
  38. clearskies_aws/input_outputs/lambda_alb.py +53 -0
  39. clearskies_aws/input_outputs/lambda_api_gateway.py +123 -0
  40. clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +73 -0
  41. clearskies_aws/input_outputs/lambda_input_output.py +89 -0
  42. clearskies_aws/input_outputs/lambda_invoke.py +88 -0
  43. clearskies_aws/input_outputs/lambda_sns.py +88 -0
  44. clearskies_aws/input_outputs/lambda_sqs_standard.py +86 -0
  45. clearskies_aws/mocks/__init__.py +1 -0
  46. clearskies_aws/mocks/actions/__init__.py +6 -0
  47. clearskies_aws/mocks/actions/ses.py +34 -0
  48. clearskies_aws/mocks/actions/sns.py +29 -0
  49. clearskies_aws/mocks/actions/sqs.py +29 -0
  50. clearskies_aws/mocks/actions/step_function.py +32 -0
  51. clearskies_aws/models/__init__.py +1 -0
  52. clearskies_aws/models/web_socket_connection_model.py +182 -0
  53. clearskies_aws/secrets/__init__.py +13 -0
  54. clearskies_aws/secrets/additional_configs/__init__.py +62 -0
  55. clearskies_aws/secrets/additional_configs/iam_db_auth.py +39 -0
  56. clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +96 -0
  57. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +80 -0
  58. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +162 -0
  59. clearskies_aws/secrets/akeyless_with_ssm_cache.py +60 -0
  60. clearskies_aws/secrets/parameter_store.py +52 -0
  61. clearskies_aws/secrets/secrets.py +16 -0
  62. clearskies_aws/secrets/secrets_manager.py +96 -0
  63. clear_skies_aws-2.0.1.dist-info/RECORD +0 -4
  64. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+ from typing import Any
5
+
6
+ from clearskies.authentication import Public
7
+ from clearskies.contexts.context import Context
8
+
9
+ from clearskies_aws.input_outputs import LambdaSqsStandard as LambdaSqsStandardInputOutput
10
+
11
+
12
+ class LambdaSqsStandard(Context):
13
+ """
14
+ Process messages from an SQS Standard Queue with Lambda.
15
+
16
+ Use this context when your application lives in a Lambda and is attached to an SQS standard
17
+ queue. Lambda always uses batch processing in this case, and will invoke your clearskies application
18
+ with a batch of messags. This clearskies context will then in turn invoke your application once
19
+ for every batched message. As a result, `request_data` will contain the contents of an individual message
20
+ from the queue, rather than the original group of batched events from Lambda. If any exception is thrown,
21
+ every other message in the queue will still be sent to your application, and clearskies will inform
22
+ AWS that the message and question failed to process.
23
+
24
+ ### Usage
25
+
26
+ Here's a very simple example:
27
+
28
+ ```
29
+ import clearskies
30
+
31
+
32
+ def some_function(request_data):
33
+ return print(request_data)
34
+
35
+
36
+ lambda_sqs = clearskies_aws.contexts.LambdaSqsStandard(
37
+ clearskies.endpoints.Callable(
38
+ some_function,
39
+ ),
40
+ )
41
+
42
+
43
+ def lambda_handler(event, context):
44
+ return lambda_sqs(event, context)
45
+ ```
46
+
47
+ `lambda_handler` would then be attached to your lambda function, which is attached to some standard SQS.
48
+
49
+ Like the other lambda contexts which don't exist in an HTTP world, you can also attach a clearskies application
50
+ with routing and hard-code the path to invoke inside the lambda handler itself. This is handy if you have
51
+ a few related lambdas with similar configuration (since you only have to build a single application) or if
52
+ you have an application that already exists and you want to invoke some specific endpoint with an SQS:
53
+
54
+ ```
55
+ import clearskies
56
+
57
+
58
+ def some_function(request_data):
59
+ return request_data
60
+
61
+
62
+ def some_other_function(request_data):
63
+ return request_data
64
+
65
+
66
+ def something_else(request_data):
67
+ return request_data
68
+
69
+
70
+ lambda_invoke = clearskies_aws.contexts.LambdaSqsStandard(
71
+ clearskies.endpoints.EndpointGroup(
72
+ [
73
+ clearskies.endpoints.Callable(
74
+ some_function,
75
+ url="some_function",
76
+ ),
77
+ clearskies.endpoints.Callable(
78
+ some_other_function,
79
+ url="some_other_function",
80
+ ),
81
+ clearskies.endpoints.Callable(
82
+ something_else,
83
+ url="something_else",
84
+ ),
85
+ ]
86
+ )
87
+ )
88
+
89
+
90
+ def some_function_handler(event, context):
91
+ return lambda_invoke(event, context, url="some_function")
92
+
93
+
94
+ def some_other_function_handler(event, context):
95
+ return lambda_invoke(event, context, url="some_other_function")
96
+
97
+
98
+ def something_else_handler(event, context):
99
+ return lambda_invoke(event, context, url="something_else")
100
+ ```
101
+
102
+ ### Context Specifics
103
+
104
+ When using this context, the following named parameters become available to inject into any callable
105
+ invoked by clearskies:
106
+
107
+ ```
108
+ | Name | Type | Description |
109
+ |:---------------------------:|:----------------:|--------------------------------------------------------|
110
+ | `event` | `dict[str, Any]` | The lambda `event` object |
111
+ | `context` | `dict[str, Any]` | The lambda `context` object |
112
+ | `message_id` | `str` | The AWS message id |
113
+ | `receipt_handle` | `str` | The receipt handle |
114
+ | `source_arn` | `str` | The ARN of the SQS the lambda is receiving events from |
115
+ | `sent_timestamp` | `str` | The timestamp when the message was sent |
116
+ | `approximate_receive_count` | `str` | The approximate receive count |
117
+ | `message_attributes` | `dict[str, Any]` | The message attributes |
118
+ | `record` | `dict[str, Any]` | The full record of the message being processed |
119
+ ```
120
+
121
+ """
122
+
123
+ def __call__(
124
+ self, event: dict[str, Any], context: dict[str, Any], url: str = "", request_method: str = ""
125
+ ) -> dict[str, Any]: # type: ignore[override]
126
+ item_failures = []
127
+ for record in event["Records"]:
128
+ try:
129
+ self.execute_application(
130
+ LambdaSqsStandardInputOutput(record, event, context, url=url, request_method=request_method)
131
+ )
132
+ except Exception as e:
133
+ item_failures.append({"itemIdentifier": record["messageId"]})
134
+
135
+ if item_failures:
136
+ return {
137
+ "batchItemFailures": item_failures,
138
+ }
139
+ return {}
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from clearskies_aws.di import inject
4
+ from clearskies_aws.di.aws_additional_config_auto_import import AwsAdditionalConfigAutoImport
5
+
6
+ __all__ = ["inject", "AwsAdditionalConfigAutoImport"]
@@ -0,0 +1,37 @@
1
+ import datetime
2
+ from types import ModuleType
3
+ from typing import Any
4
+
5
+ import boto3 as boto3_module
6
+ from clearskies import Environment
7
+ from clearskies.di import AdditionalConfigAutoImport
8
+ from clearskies.di.additional_config import AdditionalConfig
9
+
10
+ from clearskies_aws.secrets import ParameterStore
11
+
12
+
13
+ class AwsAdditionalConfigAutoImport(AdditionalConfigAutoImport):
14
+ """
15
+ Provide a DI with AWS modules built-in.
16
+
17
+ This DI auto injects boto3, boto3 Session and the parameter store.
18
+ """
19
+
20
+ def provide_boto3(self) -> ModuleType:
21
+ import boto3
22
+
23
+ return boto3
24
+
25
+ def provide_parameter_store(self) -> ParameterStore:
26
+ # This is just here so that we can auto-inject the secrets into the environment without having
27
+ # to force the developer to define a secrets manager
28
+ return ParameterStore()
29
+
30
+ def provide_boto3_session(self, boto3: ModuleType, environment: Environment) -> boto3_module.session.Session:
31
+ if not environment.get("AWS_REGION", True):
32
+ raise ValueError(
33
+ "To use AWS Session you must use set AWS_REGION in the .env file or an environment variable"
34
+ )
35
+
36
+ session = boto3.session.Session(region_name=environment.get("AWS_REGION", True))
37
+ return session
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from clearskies_aws.di.inject.boto3 import Boto3
4
+ from clearskies_aws.di.inject.boto3_session import Boto3Session
5
+
6
+ __all__ = ["Boto3", "Boto3Session"]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from types import ModuleType
4
+
5
+ from clearskies.di.injectable import Injectable
6
+
7
+
8
+ class Boto3(Injectable):
9
+ def __init__(self, cache: bool = True):
10
+ self.cache = cache
11
+
12
+ def __get__(self, instance, parent) -> ModuleType:
13
+ if instance is None:
14
+ return self # type: ignore
15
+ return self._di.build_from_name("boto3", cache=self.cache)
@@ -0,0 +1,13 @@
1
+ from types import ModuleType
2
+
3
+ from clearskies.di.injectable import Injectable
4
+
5
+
6
+ class Boto3Session(Injectable):
7
+ def __init__(self, cache: bool = True):
8
+ self.cache = cache
9
+
10
+ def __get__(self, instance, parent) -> ModuleType:
11
+ if instance is None:
12
+ return self # type: ignore
13
+ return self._di.build_from_name("boto3_session", cache=self.cache)
@@ -0,0 +1,15 @@
1
+ from clearskies.di.injectable import Injectable
2
+
3
+ from clearskies_aws.secrets.parameter_store import (
4
+ ParameterStore as ParameterStoreDependency,
5
+ )
6
+
7
+
8
+ class ParameterStore(Injectable):
9
+ def __init__(self, cache: bool = True):
10
+ self.cache = cache
11
+
12
+ def __get__(self, instance, parent) -> ParameterStoreDependency:
13
+ if instance is None:
14
+ return self # type: ignore
15
+ return self._di.build_from_name("parameter_store", cache=self.cache)
@@ -0,0 +1 @@
1
+ from .secrets_manager_rotation import SecretsManagerRotation
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ import botocore
8
+ import clearskies
9
+ from clearskies import Endpoint
10
+ from clearskies.configs import Callable as CallableConfig
11
+ from clearskies.configs import Schema as SchemaConfig
12
+ from clearskies.configs import StringList
13
+ from clearskies.decorators import parameters_to_properties
14
+ from clearskies.di.inject import Di
15
+ from clearskies.exceptions import ClientError
16
+ from clearskies.input_outputs import InputOutput
17
+
18
+ from clearskies_aws.di import inject
19
+
20
+
21
+ class SecretsManagerRotation(Endpoint):
22
+ di = Di()
23
+ boto3 = inject.Boto3()
24
+
25
+ current = "AWSCURRENT"
26
+ pending = "AWSPENDING"
27
+
28
+ steps = StringList(default=["createSecret", "setSecret", "testSecret", "finishSecret"])
29
+ create_secret = CallableConfig(default=None)
30
+ set_secret = CallableConfig(default=None)
31
+ test_secret = CallableConfig(default=None)
32
+ finish_secret = CallableConfig(default=None)
33
+ schema = SchemaConfig(default=None)
34
+
35
+ @parameters_to_properties
36
+ def __init__(
37
+ self,
38
+ steps: list[str] | None,
39
+ create_secret: Callable | None,
40
+ set_secret: Callable | None,
41
+ test_secret: Callable | None,
42
+ finish_secret: Callable | None,
43
+ schema: list[dict] | None,
44
+ ):
45
+ super().__init__()
46
+
47
+ def configure(self):
48
+ self.finalize_and_validate_configuration()
49
+ class_name = self.__class__.__name__
50
+ if not self.create_secret:
51
+ raise KeyError(f"Missing required configuration 'createSecret' for handler {class_name}")
52
+
53
+ for config_name in self.steps:
54
+ config = getattr(self, config_name)
55
+ if config is None:
56
+ continue
57
+
58
+ def handle(self, input_output: InputOutput):
59
+ request_data = json.loads(input_output.get_body())
60
+
61
+ self.find_input_errors(request_data, input_output, self.schema)
62
+
63
+ arn = request_data.get("SecretId")
64
+ request_token = request_data.get("ClientRequestToken")
65
+ step = request_data.get("Step")
66
+ secretsmanager = self.boto3.client("secretsmanager")
67
+ metadata = secretsmanager.describe_secret(SecretId=arn)
68
+
69
+ self._validate_secret_and_request(step, arn, metadata, request_token)
70
+
71
+ current_secret_data = {}
72
+ pending_secret_data = {}
73
+
74
+ current_secret = secretsmanager.get_secret_value(SecretId=arn, VersionStage=self.current)
75
+ current_secret_data = json.loads(current_secret["SecretString"])
76
+
77
+ # validate the current secret
78
+ secret_errors = {
79
+ **self._extra_column_errors(current_secret_data),
80
+ **self._find_input_errors(current_secret_data),
81
+ }
82
+ if secret_errors:
83
+ raise ValueError(f"The current secret did not match the configured schema: {secret_errors}")
84
+
85
+ # check for a pending secret. Note that this is not always available. In the event that we are retrying a failed
86
+ # rotation it will already be set, in which case we need to skip the createSecret step.
87
+ try:
88
+ pending_secret = secretsmanager.get_secret_value(
89
+ SecretId=arn, VersionId=request_token, VersionStage=self.pending
90
+ )
91
+ pending_secret_data = json.loads(pending_secret["SecretString"])
92
+ except botocore.exceptions.ClientError as error:
93
+ if error.response["Error"]["Code"] == "ResourceNotFoundException":
94
+ pending_secret_data = None
95
+ else:
96
+ raise error
97
+
98
+ # we can't call the createSecret step if we already have a pending secret or this will generate an error from AWS.
99
+ if step == "createSecret" and pending_secret_data is not None:
100
+ return
101
+
102
+ # call the appropriate step and pass along *everything*.
103
+ getattr(self, step)(
104
+ current_secret_data=current_secret_data,
105
+ pending_secret_data=pending_secret_data,
106
+ secretsmanager=secretsmanager,
107
+ metadata=metadata,
108
+ request_token=request_token,
109
+ arn=arn,
110
+ )
111
+
112
+ def _validate_secret_and_request(self, step: str, arn: str, metadata: dict[str, Any], request_token: str):
113
+ """Perform basic checks suggested by AWS of both the request and the secret to ensure validity."""
114
+ if step not in self.steps:
115
+ raise ClientError(f"Invalid step: {step}")
116
+
117
+ if not metadata.get("RotationEnabled"):
118
+ raise ValueError("Secret %s is not enabled for rotation" % arn)
119
+
120
+ versions = metadata["VersionIdsToStages"]
121
+ prefix = f"Rotation config error for version '{request_token}' of secret '{arn}': "
122
+ if request_token not in versions:
123
+ raise ValueError(f"{prefix} we don't have a stage for rotation")
124
+ if self.current in versions[request_token]:
125
+ raise ValueError(
126
+ f"{prefix} it's already the current version, which shouldn't happen. I'm quitting with prejudice."
127
+ )
128
+ elif self.pending not in versions[request_token]:
129
+ raise ValueError(f"{prefix} it hasn't been set to pending yet, which makes no sense!")
130
+
131
+ def createSecret(self, **kwargs):
132
+ new_secret_data = self.di.call_function(self.create_secret, **kwargs)
133
+ if new_secret_data is None:
134
+ raise ValueError(
135
+ f"I called the configured createSecret function but it didn't return anything. It has to return the new secret data."
136
+ )
137
+ if not isinstance(new_secret_data, dict):
138
+ raise ValueError(
139
+ f"I called the configured createSecret function but it didn't return a dictionary. The createSecret function must return a dictionary."
140
+ )
141
+
142
+ secret_errors = {
143
+ **self._extra_column_errors(new_secret_data),
144
+ **self.find_input_errors(new_secret_data),
145
+ }
146
+ if secret_errors:
147
+ raise ValueError(
148
+ f"The secret data returned by the call to createSecret did not match the configured schema: {secret_errors}"
149
+ )
150
+
151
+ # if we get this far we can store the new data
152
+ secretsmanager = kwargs["secretsmanager"]
153
+ request_token = kwargs["request_token"]
154
+ arn = kwargs["arn"]
155
+ secretsmanager.put_secret_value(
156
+ SecretId=arn,
157
+ SecretString=json.dumps(new_secret_data),
158
+ ClientRequestToken=request_token,
159
+ VersionStages=[self.pending],
160
+ )
161
+
162
+ def setSecret(self, **kwargs):
163
+ if not self._configuration.get("setSecret"):
164
+ return
165
+ self._di.call_function(self._configuration["setSecret"], **kwargs)
166
+
167
+ def testSecret(self, **kwargs):
168
+ if not self._configuration.get("testSecret"):
169
+ return
170
+ self._di.call_function(self._configuration["testSecret"], **kwargs)
171
+
172
+ def finishSecret(self, **kwargs):
173
+ if self._configuration.get("finishSecret"):
174
+ self._di.call_function(self._configuration["finishSecret"], **kwargs)
175
+
176
+ secretsmanager = kwargs["secretsmanager"]
177
+ request_token = kwargs["request_token"]
178
+ arn = kwargs["arn"]
179
+ metadata = kwargs["metadata"]
180
+ current_version = None
181
+ for version in metadata["VersionIdsToStages"]:
182
+ if self.current not in metadata["VersionIdsToStages"][version]:
183
+ continue
184
+
185
+ if version == request_token:
186
+ return
187
+
188
+ current_version = version
189
+ break
190
+
191
+ # finish the rotation by taking the new version and making it current.
192
+ secretsmanager.update_secret_version_stage(
193
+ SecretId=arn, VersionStage=self.current, MoveToVersionId=request_token, RemoveFromVersionId=current_version
194
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from clearskies import Endpoint
7
+ from clearskies.configs import AnyDict, String
8
+ from clearskies.di.inject import Di
9
+ from clearskies.input_outputs import InputOutput
10
+
11
+
12
+ class SimpleBodyRouting(Endpoint):
13
+ di = Di()
14
+
15
+ route_key = String(default="route")
16
+ routes = AnyDict(default={})
17
+
18
+ def __init__(self, routes: dict[str, Any], route_key: str = "route"):
19
+ self.routes = routes
20
+ self.route_key = route_key
21
+
22
+ def handle(self, input_output: InputOutput) -> Any:
23
+ body = json.loads(input_output.get_body()) if input_output.has_body() else {}
24
+
25
+ if not body or not body.get(self.route_key):
26
+ return self.error(input_output, "Not Found", 404)
27
+
28
+ route = body[self.route_key]
29
+ if route not in self.routes:
30
+ return self.error(input_output, "Not Found", 404)
31
+ return input_output.respond(
32
+ self.di.call_function(
33
+ self.routes[route],
34
+ request_data=body,
35
+ **input_output.context_specifics(),
36
+ ),
37
+ 200,
38
+ )
39
+
40
+ def documentation(self):
41
+ return []
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from clearskies_aws.input_outputs.cli_web_socket_mock import CliWebSocketMock
4
+ from clearskies_aws.input_outputs.lambda_alb import LambdaAlb
5
+ from clearskies_aws.input_outputs.lambda_api_gateway import LambdaApiGateway
6
+ from clearskies_aws.input_outputs.lambda_api_gateway_web_socket import (
7
+ LambdaApiGatewayWebSocket,
8
+ )
9
+ from clearskies_aws.input_outputs.lambda_invoke import LambdaInvoke
10
+ from clearskies_aws.input_outputs.lambda_sns import LambdaSns
11
+ from clearskies_aws.input_outputs.lambda_sqs_standard import LambdaSqsStandard
12
+
13
+ __all__ = [
14
+ "CliWebSocketMock",
15
+ "LambdaApiGateway",
16
+ "LambdaApiGatewayWebSocket",
17
+ "LambdaAlb",
18
+ "LambdaInvoke",
19
+ "LambdaSns",
20
+ "LambdaSqsStandard",
21
+ ]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import clearskies
6
+
7
+
8
+ class CliWebSocketMock(clearskies.input_outputs.Cli):
9
+ def context_specifics(self):
10
+ connection_id = json.loads(self.get_body()).get("connection_id")
11
+ if not connection_id:
12
+ raise KeyError("When using the CliWebsocketMock you must provide connection_id in the request body")
13
+
14
+ return {
15
+ "event": {},
16
+ "context": {},
17
+ "connection_id": connection_id,
18
+ "domain": "",
19
+ "stage": "",
20
+ }
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from clearskies.configs import String
6
+ from clearskies.input_outputs import Headers
7
+
8
+ from clearskies_aws.input_outputs import lambda_input_output
9
+
10
+
11
+ class LambdaAlb(lambda_input_output.LambdaInputOutput):
12
+ """Application Load Balancer specific Lambda input/output handler."""
13
+
14
+ def __init__(self, event: dict[str, Any], context: dict[str, Any]):
15
+ # Call parent constructor
16
+ super().__init__(event, context)
17
+
18
+ # ALB specific initialization
19
+ self.request_method = event.get("httpMethod", "GET").upper()
20
+ self.path = event.get("path", "/")
21
+
22
+ # Extract query parameters (ALB only has single value query parameters)
23
+ self.query_parameters = event.get("queryStringParameters") or {}
24
+
25
+ # Extract headers (ALB only has single value headers)
26
+ headers_dict = {}
27
+ for key, value in event.get("headers", {}).items():
28
+ headers_dict[key.lower()] = str(value)
29
+
30
+ self.request_headers = Headers(headers_dict)
31
+
32
+ def get_client_ip(self) -> str:
33
+ """Get the client IP address from ALB headers."""
34
+ # ALB always provides client IP via X-Forwarded-For header
35
+ forwarded_for = self.request_headers.get("x-forwarded-for")
36
+ if not forwarded_for:
37
+ raise KeyError(
38
+ "The x-forwarded-for header wasn't present in the request, and it should always exist for anything behind an ALB. You are probably using the wrong context."
39
+ )
40
+
41
+ # X-Forwarded-For can contain multiple IPs, take the first one
42
+ return forwarded_for.split(",")[0].strip()
43
+
44
+ def context_specifics(self) -> dict[str, Any]:
45
+ """Provide ALB specific context data."""
46
+ request_context = self.event.get("requestContext", {})
47
+ elb = request_context.get("elb", {})
48
+
49
+ return {
50
+ **super().context_specifics(),
51
+ "path": self.path,
52
+ "target_group_arn": elb.get("targetGroupArn"),
53
+ }