Skip to content

Commit 82ca923

Browse files
committed
conflicts
2 parents fc4ea60 + 6d23766 commit 82ca923

30 files changed

+2258
-195
lines changed

.github/workflows/aws-main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ jobs:
286286
runs-on: ubuntu-latest
287287
# only remove the image artifacts if the build was successful
288288
# (this allows a re-build of failed jobs until for the time of the retention period)
289-
if: success()
289+
if: always() && !failure() && !cancelled()
290290
needs: push
291291
steps:
292292
- uses: geekyeggo/delete-artifact@v5

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
repos:
44
- repo: https://github.com/astral-sh/ruff-pre-commit
55
# Ruff version.
6-
rev: v0.11.11
6+
rev: v0.11.12
77
hooks:
88
- id: ruff
99
args: [--fix, --exit-non-zero-on-fix]
1010
# Run the formatter.
1111
- id: ruff-format
1212

1313
- repo: https://github.com/pre-commit/mirrors-mypy
14-
rev: v1.15.0
14+
rev: v1.16.0
1515
hooks:
1616
- id: mypy
1717
entry: bash -c 'cd localstack-core && mypy --install-types --non-interactive'

localstack-core/localstack/dns/server.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,31 @@ def handle(self, *args, **kwargs):
258258
pass
259259

260260

261+
# List of unique non-subdomain prefixes (e.g., data-) from endpoint.hostPrefix in the botocore specs.
262+
# Subdomain-prefixes (e.g., api.) work properly unless DNS rebind protection blocks DNS resolution, but
263+
# these `-` dash-prefixes require special consideration.
264+
# IMPORTANT: Adding a new host prefix here requires deploying a public DNS entry to ensure proper DNS resolution for
265+
# such non-dot prefixed domains (e.g., data-localhost.localstack.cloud)
266+
# LIMITATION: As of 2025-05-26, only used prefixes are deployed to our public DNS, including `sync-` and `data-`
267+
HOST_PREFIXES_NO_SUBDOMAIN = [
268+
"analytics-",
269+
"control-storage-",
270+
"data-",
271+
"query-",
272+
"runtime-",
273+
"storage-",
274+
"streaming-",
275+
"sync-",
276+
"tags-",
277+
"workflows-",
278+
]
279+
HOST_PREFIX_NAME_PATTERNS = [
280+
f"{host_prefix}{LOCALHOST_HOSTNAME}" for host_prefix in HOST_PREFIXES_NO_SUBDOMAIN
281+
]
282+
261283
NAME_PATTERNS_POINTING_TO_LOCALSTACK = [
262284
f".*{LOCALHOST_HOSTNAME}",
285+
*HOST_PREFIX_NAME_PATTERNS,
263286
]
264287

265288

localstack-core/localstack/services/apigateway/legacy/provider.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def update_rest_api(
360360

361361
fixed_patch_ops.append(patch_op)
362362

363-
_patch_api_gateway_entity(rest_api, fixed_patch_ops)
363+
patch_api_gateway_entity(rest_api, fixed_patch_ops)
364364

365365
# fix data types after patches have been applied
366366
endpoint_configs = rest_api.endpoint_configuration or {}
@@ -622,7 +622,7 @@ def update_integration_response(
622622
param = param.replace("~1", "/")
623623
if op == "remove":
624624
integration_response.response_templates.pop(param)
625-
elif op == "add":
625+
elif op in ("add", "replace"):
626626
integration_response.response_templates[param] = value
627627

628628
elif "/contentHandling" in path and op == "replace":
@@ -684,7 +684,7 @@ def update_resource(
684684
)
685685

686686
# TODO: test with multiple patch operations which would not be compatible between each other
687-
_patch_api_gateway_entity(moto_resource, patch_operations)
687+
patch_api_gateway_entity(moto_resource, patch_operations)
688688

689689
# after setting it, mutate the store
690690
if moto_resource.parent_id != current_parent_id:
@@ -914,7 +914,7 @@ def update_method(
914914
]
915915

916916
# TODO: test with multiple patch operations which would not be compatible between each other
917-
_patch_api_gateway_entity(moto_method, applicable_patch_operations)
917+
patch_api_gateway_entity(moto_method, applicable_patch_operations)
918918

919919
# if we removed all values of those fields, set them to None so that they're not returned anymore
920920
if had_req_params and len(moto_method.request_parameters) == 0:
@@ -1074,7 +1074,7 @@ def update_stage(
10741074
if patch_path == "/tracingEnabled" and (value := patch_operation.get("value")):
10751075
patch_operation["value"] = value and value.lower() == "true" or False
10761076

1077-
_patch_api_gateway_entity(moto_stage, patch_operations)
1077+
patch_api_gateway_entity(moto_stage, patch_operations)
10781078
moto_stage.apply_operations(patch_operations)
10791079

10801080
response = moto_stage.to_json()
@@ -1464,7 +1464,7 @@ def update_documentation_version(
14641464
if not result:
14651465
raise NotFoundException(f"Documentation version not found: {documentation_version}")
14661466

1467-
_patch_api_gateway_entity(result, patch_operations)
1467+
patch_api_gateway_entity(result, patch_operations)
14681468

14691469
return result
14701470

@@ -2011,7 +2011,7 @@ def update_integration(
20112011
raise NotFoundException("Invalid Integration identifier specified")
20122012

20132013
integration = method.method_integration
2014-
_patch_api_gateway_entity(integration, patch_operations)
2014+
patch_api_gateway_entity(integration, patch_operations)
20152015

20162016
# fix data types
20172017
if integration.timeout_in_millis:
@@ -2617,7 +2617,7 @@ def update_gateway_response(
26172617
f"Invalid null or empty value in {param_type}"
26182618
)
26192619

2620-
_patch_api_gateway_entity(patched_entity, patch_operations)
2620+
patch_api_gateway_entity(patched_entity, patch_operations)
26212621

26222622
return patched_entity
26232623

@@ -2739,7 +2739,7 @@ def create_custom_context(
27392739
return ctx
27402740

27412741

2742-
def _patch_api_gateway_entity(entity: Any, patch_operations: ListOfPatchOperation):
2742+
def patch_api_gateway_entity(entity: Any, patch_operations: ListOfPatchOperation):
27432743
patch_operations = patch_operations or []
27442744

27452745
if isinstance(entity, dict):

localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from rolo.gateway import RequestContext
66
from werkzeug.datastructures import Headers
77

8-
from localstack.aws.api.apigateway import Integration, Method, Resource
8+
from localstack.aws.api.apigateway import Integration, Method, Resource, Stage
99
from localstack.services.apigateway.models import RestApiDeployment
1010

1111
from .variables import ContextVariableOverrides, ContextVariables, LoggingContextVariables
@@ -79,7 +79,7 @@ class RestApiInvocationContext(RequestContext):
7979
api_id: Optional[str]
8080
"""The REST API identifier of the invoked API"""
8181
stage: Optional[str]
82-
"""The REST API stage linked to this invocation"""
82+
"""The REST API stage name linked to this invocation"""
8383
base_path: Optional[str]
8484
"""The REST API base path mapped to the stage of this invocation"""
8585
deployment_id: Optional[str]
@@ -96,6 +96,10 @@ class RestApiInvocationContext(RequestContext):
9696
"""The method of the resource the invocation matched"""
9797
stage_variables: Optional[dict[str, str]]
9898
"""The Stage variables, also used in parameters mapping and mapping templates"""
99+
stage_configuration: Optional[Stage]
100+
"""The Stage configuration, containing canary deployment settings"""
101+
is_canary: Optional[bool]
102+
"""If the current call was directed to a canary deployment"""
99103
context_variables: Optional[ContextVariables]
100104
"""The $context used in data models, authorizers, mapping templates, and CloudWatch access logging"""
101105
context_variable_overrides: Optional[ContextVariableOverrides]
@@ -126,6 +130,8 @@ def __init__(self, request: Request):
126130
self.resource_method = None
127131
self.integration = None
128132
self.stage_variables = None
133+
self.stage_configuration = None
134+
self.is_canary = None
129135
self.context_variables = None
130136
self.logging_context_variables = None
131137
self.integration_request = None

localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from ..context import InvocationRequest, RestApiInvocationContext
1818
from ..header_utils import should_drop_header_from_invocation
1919
from ..helpers import generate_trace_id, generate_trace_parent, parse_trace_id
20-
from ..moto_helpers import get_stage_variables
2120
from ..variables import (
2221
ContextVariableOverrides,
2322
ContextVariables,
@@ -53,7 +52,7 @@ def parse_and_enrich(self, context: RestApiInvocationContext):
5352
# TODO: maybe adjust the logging
5453
LOG.debug("Initializing $context='%s'", context.context_variables)
5554
# then populate the stage variables
56-
context.stage_variables = self.fetch_stage_variables(context)
55+
context.stage_variables = self.get_stage_variables(context)
5756
LOG.debug("Initializing $stageVariables='%s'", context.stage_variables)
5857

5958
context.trace_id = self.populate_trace_id(context.request.headers)
@@ -173,18 +172,21 @@ def create_context_variables(context: RestApiInvocationContext) -> ContextVariab
173172
requestTimeEpoch=int(now.timestamp() * 1000),
174173
stage=context.stage,
175174
)
175+
if context.is_canary is not None:
176+
context_variables["isCanaryRequest"] = context.is_canary
177+
176178
return context_variables
177179

178180
@staticmethod
179-
def fetch_stage_variables(context: RestApiInvocationContext) -> Optional[dict[str, str]]:
180-
stage_variables = get_stage_variables(
181-
account_id=context.account_id,
182-
region=context.region,
183-
api_id=context.api_id,
184-
stage_name=context.stage,
185-
)
181+
def get_stage_variables(context: RestApiInvocationContext) -> Optional[dict[str, str]]:
182+
stage_variables = context.stage_configuration.get("variables")
183+
if context.is_canary:
184+
overrides = (
185+
context.stage_configuration["canarySettings"].get("stageVariableOverrides") or {}
186+
)
187+
stage_variables = (stage_variables or {}) | overrides
188+
186189
if not stage_variables:
187-
# we need to set the stage variables to None in the context if we don't have at least one
188190
return None
189191

190192
return stage_variables

localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import copy
22
import logging
3+
import random
34
import re
45
import time
56
from secrets import token_hex
@@ -174,3 +175,9 @@ def mime_type_matches_binary_media_types(mime_type: str | None, binary_media_typ
174175
return True
175176

176177
return False
178+
179+
180+
def should_divert_to_canary(percent_traffic: float) -> bool:
181+
if int(percent_traffic) == 100:
182+
return True
183+
return percent_traffic > random.random() * 100

localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
from moto.apigateway.models import APIGatewayBackend, apigateway_backends
22
from moto.apigateway.models import RestAPI as MotoRestAPI
33

4-
from localstack.aws.api.apigateway import ApiKey, ListOfUsagePlan, ListOfUsagePlanKey, Resource
4+
from localstack.aws.api.apigateway import (
5+
ApiKey,
6+
ListOfUsagePlan,
7+
ListOfUsagePlanKey,
8+
Resource,
9+
Stage,
10+
)
511

612

713
def get_resources_from_moto_rest_api(moto_rest_api: MotoRestAPI) -> dict[str, Resource]:
@@ -40,6 +46,13 @@ def get_stage_variables(
4046
return stage.variables
4147

4248

49+
def get_stage_configuration(account_id: str, region: str, api_id: str, stage_name: str) -> Stage:
50+
apigateway_backend: APIGatewayBackend = apigateway_backends[account_id][region]
51+
moto_rest_api = apigateway_backend.get_rest_api(api_id)
52+
stage = moto_rest_api.stages[stage_name]
53+
return stage.to_json()
54+
55+
4356
def get_usage_plans(account_id: str, region_name: str) -> ListOfUsagePlan:
4457
"""
4558
Will return a list of usage plans from the moto store.

localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from rolo.routing.handler import Handler
66
from werkzeug.routing import Rule
77

8+
from localstack.aws.api.apigateway import Stage
89
from localstack.constants import APPLICATION_JSON, AWS_REGION_US_EAST_1, DEFAULT_AWS_ACCOUNT_ID
910
from localstack.deprecations import deprecated_endpoint
1011
from localstack.http import Response
@@ -14,6 +15,8 @@
1415

1516
from .context import RestApiInvocationContext
1617
from .gateway import RestApiGateway
18+
from .helpers import should_divert_to_canary
19+
from .moto_helpers import get_stage_configuration
1720

1821
LOG = logging.getLogger(__name__)
1922

@@ -88,11 +91,41 @@ def populate_rest_api_invocation_context(
8891
# TODO: find proper error when trying to hit an API with no deployment/stage linked
8992
return
9093

94+
stage_configuration = self.fetch_stage_configuration(
95+
account_id=frozen_deployment.account_id,
96+
region=frozen_deployment.region,
97+
api_id=api_id,
98+
stage_name=stage,
99+
)
100+
if canary_settings := stage_configuration.get("canarySettings"):
101+
if should_divert_to_canary(canary_settings["percentTraffic"]):
102+
deployment_id = canary_settings["deploymentId"]
103+
frozen_deployment = self._global_store.internal_deployments[api_id][deployment_id]
104+
context.is_canary = True
105+
else:
106+
context.is_canary = False
107+
91108
context.deployment = frozen_deployment
92109
context.api_id = api_id
93110
context.stage = stage
111+
context.stage_configuration = stage_configuration
94112
context.deployment_id = deployment_id
95113

114+
@staticmethod
115+
def fetch_stage_configuration(
116+
account_id: str, region: str, api_id: str, stage_name: str
117+
) -> Stage:
118+
# this will be migrated once we move away from Moto, so we won't need the helper anymore and the logic will
119+
# be implemented here
120+
stage_variables = get_stage_configuration(
121+
account_id=account_id,
122+
region=region,
123+
api_id=api_id,
124+
stage_name=stage_name,
125+
)
126+
127+
return stage_variables
128+
96129
@staticmethod
97130
def create_response(request: Request) -> Response:
98131
# Creates a default apigw response.

localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class ContextVariables(TypedDict, total=False):
112112
httpMethod: str
113113
"""The HTTP method used"""
114114
identity: Optional[ContextVarsIdentity]
115-
isCanaryRequest: Optional[bool | str] # TODO: verify type
115+
isCanaryRequest: Optional[bool]
116116
"""Indicates if the request was directed to the canary"""
117117
path: str
118118
"""The request path."""

0 commit comments

Comments
 (0)