Skip to content

Commit 0932afc

Browse files
whummerclaude
andcommitted
add proxy tests for the CloudWatch service (metrics + logs)
- Add test file with tests for CloudWatch Metrics and CloudWatch Logs - Fix service name mapping (monitoring -> cloudwatch) in auth_proxy and forwarder - Use botocore service model for protocol compatibility (LocalStack uses smithy-rpc-v2-cbor, boto3 uses query protocol) - Implement resource name matching for CloudWatch and CloudWatch Logs Tests added: - test_cloudwatch_metric_operations: PutMetricData and ListMetrics - test_cloudwatch_alarm_operations: PutMetricAlarm and DescribeAlarms - test_cloudwatch_readonly_operations (xfail): read-only mode - test_cloudwatch_resource_name_matching (xfail): resource pattern matching - test_logs_group_operations: CreateLogGroup and DescribeLogGroups - test_logs_stream_and_events: log streams and events - test_logs_readonly_operations: read-only mode for logs - test_logs_resource_name_matching: resource pattern matching for logs - test_logs_filter_log_events: FilterLogEvents operation Known limitations (2 xfail tests): - CloudWatch read_only and resource_name_matching: form data stream consumed by LocalStack before proxy can access it (Query protocol issue) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 75c4d82 commit 0932afc

File tree

3 files changed

+624
-7
lines changed

3 files changed

+624
-7
lines changed

aws-proxy/aws_proxy/client/auth_proxy.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from botocore.awsrequest import AWSPreparedRequest
1414
from botocore.model import OperationModel
1515
from localstack import config as localstack_config
16-
from localstack.aws.spec import load_service
1716
from localstack.config import external_service_url
1817
from localstack.constants import (
1918
AWS_REGION_US_EAST_1,
@@ -51,6 +50,11 @@
5150
if localstack_config.DEBUG:
5251
LOG.setLevel(logging.DEBUG)
5352

53+
# Mapping from AWS service signing names to boto3 client names
54+
SERVICE_NAME_MAPPING = {
55+
"monitoring": "cloudwatch",
56+
}
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
)
@@ -177,7 +185,10 @@ def _parse_aws_request(
177185
) -> Tuple[OperationModel, AWSPreparedRequest, Dict]:
178186
from localstack.aws.protocol.parser import create_parser
179187

180-
parser = create_parser(load_service(service_name))
188+
# Use botocore's service model to ensure protocol compatibility
189+
# (LocalStack's load_service may return newer protocol versions that don't match the client)
190+
service_model = self._get_botocore_service_model(service_name)
191+
parser = create_parser(service_model)
181192
operation_model, parsed_request = parser.parse(request)
182193
request_context = {
183194
"client_region": region_name,
@@ -315,6 +326,22 @@ def _query_account_id_from_aws(self) -> str:
315326
result = sts_client.get_caller_identity()
316327
return result["Account"]
317328

329+
@staticmethod
330+
@cache
331+
def _get_botocore_service_model(service_name: str):
332+
"""
333+
Get the botocore service model for a service. This is used instead of LocalStack's
334+
load_service() to ensure protocol compatibility, as LocalStack may use newer protocol
335+
versions (e.g., smithy-rpc-v2-cbor) while clients use older protocols (e.g., query).
336+
"""
337+
import botocore.session
338+
from botocore.model import ServiceModel
339+
340+
session = botocore.session.get_session()
341+
loader = session.get_component("data_loader")
342+
api_data = loader.load_service_model(service_name, "service-2")
343+
return ServiceModel(api_data)
344+
318345

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

aws-proxy/aws_proxy/server/aws_request_forwarder.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,32 @@ def _request_matches_resource(
134134
secret_id, account_id=context.account_id, region_name=context.region
135135
)
136136
return bool(re.match(resource_name_pattern, secret_arn))
137-
# TODO: add more resource patterns
137+
if service_name == "cloudwatch":
138+
# CloudWatch alarm ARN format: arn:aws:cloudwatch:{region}:{account}:alarm:{alarm_name}
139+
alarm_name = context.service_request.get("AlarmName") or ""
140+
alarm_names = context.service_request.get("AlarmNames") or []
141+
if alarm_name:
142+
alarm_names = [alarm_name]
143+
if alarm_names:
144+
for name in alarm_names:
145+
alarm_arn = f"arn:aws:cloudwatch:{context.region}:{context.account_id}:alarm:{name}"
146+
if re.match(resource_name_pattern, alarm_arn):
147+
return True
148+
return False
149+
# For metric operations without alarm names, check if pattern is generic
150+
return bool(re.match(resource_name_pattern, ".*"))
151+
if service_name == "logs":
152+
# CloudWatch Logs ARN format: arn:aws:logs:{region}:{account}:log-group:{name}:*
153+
log_group_name = context.service_request.get("logGroupName") or ""
154+
log_group_prefix = (
155+
context.service_request.get("logGroupNamePrefix") or ""
156+
)
157+
name = log_group_name or log_group_prefix
158+
if name:
159+
log_group_arn = f"arn:aws:logs:{context.region}:{context.account_id}:log-group:{name}:*"
160+
return bool(re.match(resource_name_pattern, log_group_arn))
161+
# No log group name specified - check if pattern is generic
162+
return bool(re.match(resource_name_pattern, ".*"))
138163
except re.error as e:
139164
raise Exception(
140165
"Error evaluating regular expression - please verify proxy configuration"
@@ -261,6 +286,9 @@ def _get_resource_names(cls, service_config: ProxyServiceConfig) -> list[str]:
261286

262287
@classmethod
263288
def _get_canonical_service_name(cls, service_name: str) -> str:
264-
if service_name == "sqs-query":
265-
return "sqs"
266-
return service_name
289+
# Map internal/signing service names to boto3 client names
290+
mapping = {
291+
"sqs-query": "sqs",
292+
"monitoring": "cloudwatch",
293+
}
294+
return mapping.get(service_name, service_name)

0 commit comments

Comments
 (0)