Skip to content

Commit 6888e92

Browse files
whummerclaude
andauthored
enhance logic and tests for CloudWatch proxy (#121)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9b818de commit 6888e92

9 files changed

Lines changed: 889 additions & 24 deletions

File tree

aws-proxy/AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,13 @@ Some services have operations that are functionally read-only (don't modify stat
4040

4141
If you find such operations, add them to the service-specific rules in `aws_proxy/server/aws_request_forwarder.py` in the `_is_read_request` method. This ensures that read-only proxy configurations correctly forward these operations rather than blocking them.
4242

43+
**IMPORTANT**: This step is mandatory when adding a new service. Failure to identify non-standard read-only operations will cause `read_only: true` configurations to incorrectly block legitimate read requests.
44+
4345
Example services with non-standard read-only operations:
4446
- **AppSync**: `EvaluateCode`, `EvaluateMappingTemplate`
4547
- **IAM**: `SimulateCustomPolicy`, `SimulatePrincipalPolicy`
4648
- **Cognito**: `InitiateAuth`
49+
- **DynamoDB**: `Scan`, `BatchGetItem`, `PartiQLSelect`
4750

4851
When adding new integration tests, consider the following:
4952
* Include a mix of positive and negative assertions (i.e., presence and absence of resources).
@@ -52,3 +55,10 @@ When adding new integration tests, consider the following:
5255
* Make sure to either use fixtures (preferred), or reliable cleanups for removing the resources; several fixtures for creating AWS resources are available in the `localstack.testing.pytest.fixtures` module
5356
* If a test uses multiple resources with interdependencies (e.g., an SQS queue connected to an SNS topic), then the test needs to ensure that both resource types are proxied (i.e., created in real AWS), to avoid a situation where a resource in AWS is attempting to reference a local resource in LocalStack (using account ID `000000000000` in their ARN).
5457
* When waiting for the creation status of a resource, use the `localstack.utils.sync.retry(..)` utility function, rather than a manual `for` loop.
58+
* Avoid using `time.sleep()` in tests. Instead, use `localstack.utils.sync.retry(..)` to poll for the expected state. This makes tests more robust and avoids unnecessary delays when resources become available faster than expected.
59+
60+
## Fixing or Enhancing Logic in the Proxy
61+
62+
Notes:
63+
* The AWS proxy is running as a LocalStack Extension, and the tests are currently set up in a way that they assume the container to be running with the Extension in dev mode. Hence, in order to make actual changes to the proxy logic, we'll need to restart the LocalStack main container. You can either ask me (the user) to restart the container whenever you're making changes in the core logic, or alternatively remove the `localstack-main` container, and then run `EXTENSION_DEV_MODE=1 DEBUG=1 localstack start -d` again to restart the container, which may reveal some error logs, stack traces, etc.
64+
* If the proxy raises errors or something seems off, you can grab and parse the output of the LocalStack container via `localstack logs`.

aws-proxy/aws_proxy/client/auth_proxy.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# Note/disclosure: This file has been partially modified by an AI agent.
12
import json
23
import logging
34
import os
@@ -11,9 +12,11 @@
1112
import boto3
1213
import requests
1314
from botocore.awsrequest import AWSPreparedRequest
14-
from botocore.model import OperationModel
15-
from localstack import config as localstack_config
15+
from botocore.model import OperationModel, ServiceModel
16+
from botocore.session import get_session as get_botocore_session
17+
from localstack.aws.protocol.parser import create_parser
1618
from localstack.aws.spec import load_service
19+
from localstack import config as localstack_config
1720
from localstack.config import external_service_url
1821
from localstack.constants import (
1922
AWS_REGION_US_EAST_1,
@@ -43,14 +46,15 @@
4346
from aws_proxy import config as repl_config
4447
from aws_proxy.client.utils import truncate_content
4548
from aws_proxy.config import HANDLER_PATH_PROXIES
46-
from aws_proxy.shared.constants import HEADER_HOST_ORIGINAL
49+
from aws_proxy.shared.constants import HEADER_HOST_ORIGINAL, SERVICE_NAME_MAPPING
4750
from aws_proxy.shared.models import AddProxyRequest, ProxyConfig
4851

4952
LOG = logging.getLogger(__name__)
5053
LOG.setLevel(logging.INFO)
5154
if localstack_config.DEBUG:
5255
LOG.setLevel(logging.DEBUG)
5356

57+
5458
# TODO make configurable
5559
CLI_PIP_PACKAGE = "localstack-extension-aws-proxy"
5660
# note: enable the line below temporarily for testing:
@@ -86,6 +90,8 @@ def proxy_request(self, request: Request, data: bytes) -> Response:
8690
if not parsed:
8791
return requests_response("", status_code=400)
8892
region_name, service_name = parsed
93+
# Map AWS signing names to boto3 client names
94+
service_name = SERVICE_NAME_MAPPING.get(service_name, service_name)
8995
query_string = to_str(request.query_string or "")
9096

9197
LOG.debug(
@@ -97,10 +103,12 @@ def proxy_request(self, request: Request, data: bytes) -> Response:
97103
query_string,
98104
)
99105

106+
# Convert Quart headers to a dict for the LocalStack Request
107+
headers_dict = dict(request.headers)
100108
request = Request(
101109
body=data,
102110
method=request.method,
103-
headers=request.headers,
111+
headers=headers_dict,
104112
path=request.path,
105113
query_string=query_string,
106114
)
@@ -172,13 +180,46 @@ def register_in_instance(self):
172180
)
173181
raise
174182

183+
def deregister_from_instance(self):
184+
"""Deregister this proxy from the LocalStack instance."""
185+
port = getattr(self, "port", None)
186+
if not port:
187+
return
188+
url = f"{external_service_url()}{HANDLER_PATH_PROXIES}/{port}"
189+
LOG.debug("Deregistering proxy from main container via: %s", url)
190+
try:
191+
response = requests.delete(url)
192+
return response
193+
except Exception as e:
194+
LOG.debug("Unable to deregister auth proxy: %s", e)
195+
175196
def _parse_aws_request(
176197
self, request: Request, service_name: str, region_name: str, client
177198
) -> Tuple[OperationModel, AWSPreparedRequest, Dict]:
178-
from localstack.aws.protocol.parser import create_parser
199+
# Get botocore's service model for making the actual AWS request
200+
botocore_service_model = self._get_botocore_service_model(service_name)
201+
202+
# Check if request uses JSON protocol (X-Amz-Target header) while service model
203+
# uses RPC v2 CBOR. In this case, we need to parse the request manually since
204+
# create_parser would reject the X-Amz-Target header for RPC v2 services.
205+
x_amz_target = request.headers.get("X-Amz-Target") or request.headers.get(
206+
"X-Amzn-Target"
207+
)
208+
if x_amz_target and botocore_service_model.protocol == "smithy-rpc-v2-cbor":
209+
# Extract operation name from X-Amz-Target (format: "ServiceName.OperationName")
210+
operation_name = x_amz_target.split(".")[-1]
211+
operation_model = botocore_service_model.operation_model(operation_name)
212+
# Parse JSON body
213+
parsed_request = json.loads(to_str(request.data)) if request.data else {}
214+
else:
215+
# Use LocalStack's parser for other protocols
216+
localstack_service_model = load_service(service_name)
217+
parser = create_parser(localstack_service_model)
218+
ls_operation_model, parsed_request = parser.parse(request)
219+
operation_model = botocore_service_model.operation_model(
220+
ls_operation_model.name
221+
)
179222

180-
parser = create_parser(load_service(service_name))
181-
operation_model, parsed_request = parser.parse(request)
182223
request_context = {
183224
"client_region": region_name,
184225
"has_streaming_input": operation_model.has_streaming_input,
@@ -315,6 +356,19 @@ def _query_account_id_from_aws(self) -> str:
315356
result = sts_client.get_caller_identity()
316357
return result["Account"]
317358

359+
@staticmethod
360+
@cache
361+
def _get_botocore_service_model(service_name: str):
362+
"""
363+
Get the botocore service model for a service. This is used instead of LocalStack's
364+
load_service() to ensure protocol compatibility, as LocalStack may use newer protocol
365+
versions (e.g., smithy-rpc-v2-cbor) while clients use older protocols (e.g., query).
366+
"""
367+
session = get_botocore_session()
368+
loader = session.get_component("data_loader")
369+
api_data = loader.load_service_model(service_name, "service-2")
370+
return ServiceModel(api_data)
371+
318372

319373
def start_aws_auth_proxy(config: ProxyConfig, port: int = None) -> AuthProxyAWS:
320374
setup_logging()

aws-proxy/aws_proxy/server/aws_request_forwarder.py

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
# Note/disclosure: This file has been partially modified by an AI agent.
12
import json
23
import logging
34
import re
45
from typing import Dict, Optional
6+
from urllib.parse import urlencode
57

68
import requests
9+
from botocore.serialize import create_serializer
710
from localstack.aws.api import RequestContext
811
from localstack.aws.chain import Handler, HandlerChain
912
from localstack.constants import APPLICATION_JSON, LOCALHOST, LOCALHOST_HOSTNAME
@@ -22,7 +25,7 @@
2225
except ImportError:
2326
from localstack.constants import TEST_AWS_ACCESS_KEY_ID
2427

25-
from aws_proxy.shared.constants import HEADER_HOST_ORIGINAL
28+
from aws_proxy.shared.constants import HEADER_HOST_ORIGINAL, SERVICE_NAME_MAPPING
2629
from aws_proxy.shared.models import ProxyInstance, ProxyServiceConfig
2730

2831
LOG = logging.getLogger(__name__)
@@ -134,7 +137,41 @@ def _request_matches_resource(
134137
secret_id, account_id=context.account_id, region_name=context.region
135138
)
136139
return bool(re.match(resource_name_pattern, secret_arn))
137-
# TODO: add more resource patterns
140+
if service_name == "cloudwatch":
141+
# CloudWatch alarm ARN format: arn:aws:cloudwatch:{region}:{account}:alarm:{alarm_name}
142+
alarm_name = context.service_request.get("AlarmName") or ""
143+
alarm_names = context.service_request.get("AlarmNames") or []
144+
if alarm_name:
145+
alarm_names = [alarm_name]
146+
if alarm_names:
147+
for name in alarm_names:
148+
alarm_arn = f"arn:aws:cloudwatch:{context.region}:{context.account_id}:alarm:{name}"
149+
if re.match(resource_name_pattern, alarm_arn):
150+
return True
151+
return False
152+
# For metric operations without alarm names, check if pattern is generic
153+
return bool(re.match(resource_name_pattern, ".*"))
154+
if service_name == "logs":
155+
# CloudWatch Logs ARN format: arn:aws:logs:{region}:{account}:log-group:{name}:*
156+
log_group_name = context.service_request.get("logGroupName") or ""
157+
log_group_prefix = (
158+
context.service_request.get("logGroupNamePrefix") or ""
159+
)
160+
name = log_group_name or log_group_prefix
161+
if name:
162+
log_group_arn = f"arn:aws:logs:{context.region}:{context.account_id}:log-group:{name}:*"
163+
return bool(re.match(resource_name_pattern, log_group_arn))
164+
# Operations that don't have a log group name but should still be proxied
165+
# (e.g., GetQueryResults uses queryId, not logGroupName)
166+
operation_name = context.operation.name if context.operation else ""
167+
if operation_name in {
168+
"GetQueryResults",
169+
"StopQuery",
170+
"DescribeQueries",
171+
}:
172+
return True
173+
# No log group name specified - check if pattern is generic
174+
return bool(re.match(resource_name_pattern, ".*"))
138175
except re.error as e:
139176
raise Exception(
140177
"Error evaluating regular expression - please verify proxy configuration"
@@ -168,6 +205,12 @@ def forward_request(
168205
data = request.form
169206
elif request.data:
170207
data = request.data
208+
209+
# Fallback: if data is empty and we have parsed service_request,
210+
# reconstruct the request body (handles cases where form data was consumed)
211+
if not data and context.service_request:
212+
data = self._reconstruct_request_body(context, ctype)
213+
171214
LOG.debug(
172215
"Forward request: %s %s - %s - %s",
173216
request.method,
@@ -228,6 +271,18 @@ def _is_read_request(self, context: RequestContext) -> bool:
228271
"EvaluateMappingTemplate",
229272
}:
230273
return True
274+
if context.service.service_name == "logs" and operation_name in {
275+
"FilterLogEvents",
276+
"StartQuery",
277+
"GetQueryResults",
278+
"TestMetricFilter",
279+
}:
280+
return True
281+
if context.service.service_name == "monitoring" and operation_name in {
282+
"BatchGetServiceLevelObjectiveBudgetReport",
283+
"BatchGetServiceLevelIndicatorReport",
284+
}:
285+
return True
231286
# TODO: add more rules
232287
return False
233288

@@ -261,6 +316,30 @@ def _get_resource_names(cls, service_config: ProxyServiceConfig) -> list[str]:
261316

262317
@classmethod
263318
def _get_canonical_service_name(cls, service_name: str) -> str:
264-
if service_name == "sqs-query":
265-
return "sqs"
266-
return service_name
319+
return SERVICE_NAME_MAPPING.get(service_name, service_name)
320+
321+
def _reconstruct_request_body(
322+
self, context: RequestContext, content_type: str
323+
) -> bytes:
324+
"""
325+
Reconstruct the request body from the parsed service_request.
326+
This is used when the original request body was consumed during parsing.
327+
"""
328+
try:
329+
protocol = context.service.protocol
330+
if protocol == "query" or "x-www-form-urlencoded" in (content_type or ""):
331+
# For Query protocol, serialize using botocore serializer
332+
serializer = create_serializer(protocol)
333+
operation_model = context.operation
334+
serialized = serializer.serialize_to_request(
335+
context.service_request, operation_model
336+
)
337+
body = serialized.get("body", {})
338+
if isinstance(body, dict):
339+
return urlencode(body, doseq=True)
340+
return body
341+
elif protocol == "json" or protocol == "rest-json":
342+
return json.dumps(context.service_request)
343+
except Exception as e:
344+
LOG.debug("Failed to reconstruct request body: %s", e)
345+
return b""

aws-proxy/aws_proxy/server/extension.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from localstack.services.internal import get_internal_apis
1010

11-
from aws_proxy.server.request_handler import WebApp
11+
from aws_proxy.server.aws_request_forwarder import AwsProxyHandler
12+
from aws_proxy.server.request_handler import RequestHandler, WebApp
1213

1314
LOG = logging.getLogger(__name__)
1415

@@ -27,8 +28,6 @@ def on_extension_load(self):
2728
)
2829

2930
def update_gateway_routes(self, router: http.Router[http.RouteHandler]):
30-
from aws_proxy.server.request_handler import RequestHandler
31-
3231
super().update_gateway_routes(router)
3332

3433
LOG.info("AWS Proxy: adding routes to activate extension")
@@ -38,7 +37,5 @@ def collect_routes(self, routes: list[t.Any]):
3837
routes.append(WebApp())
3938

4039
def update_request_handlers(self, handlers: CompositeHandler):
41-
from aws_proxy.server.aws_request_forwarder import AwsProxyHandler
42-
4340
LOG.debug("AWS Proxy: adding AWS proxy handler to the request chain")
4441
handlers.handlers.append(AwsProxyHandler())

aws-proxy/aws_proxy/server/request_handler.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# Note/disclosure: This file has been partially modified by an AI agent.
12
import json
23
import logging
34
import os.path
@@ -43,6 +44,11 @@ def add_proxy(self, request: Request, **kwargs):
4344
result = handle_proxies_request(req)
4445
return result or {}
4546

47+
@route(f"{HANDLER_PATH_PROXIES}/<int:port>", methods=["DELETE"])
48+
def delete_proxy(self, request: Request, port: int, **kwargs):
49+
removed = AwsProxyHandler.PROXY_INSTANCES.pop(port, None)
50+
return {"removed": removed is not None}
51+
4652
@route(f"{HANDLER_PATH_PROXIES}/status", methods=["GET"])
4753
def get_status(self, request: Request, **kwargs):
4854
containers = get_proxy_containers()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
# header name for the original request host name forwarded in the request to the target proxy handler
22
HEADER_HOST_ORIGINAL = "x-ls-host-original"
3+
4+
# Mapping from AWS service signing names to boto3 client names
5+
SERVICE_NAME_MAPPING = {
6+
"monitoring": "cloudwatch",
7+
"sqs-query": "sqs",
8+
}

aws-proxy/tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# Note/disclosure: This file has been partially modified by an AI agent.
12
import os
23

34
import pytest
@@ -51,4 +52,6 @@ def _start(config: dict = None):
5152
yield _start
5253

5354
for proxy in proxies:
55+
# Deregister from LocalStack instance before shutting down
56+
proxy.deregister_from_instance()
5457
proxy.shutdown()

0 commit comments

Comments
 (0)