From bab91364ced054a0e439c144c1b9b457f34da641 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Sat, 31 May 2025 02:38:27 +0200 Subject: [PATCH 1/3] implement Canary Deployments --- .../next_gen/execute_api/context.py | 9 ++++-- .../next_gen/execute_api/handlers/parse.py | 20 ++++++------ .../next_gen/execute_api/helpers.py | 7 +++++ .../next_gen/execute_api/moto_helpers.py | 15 ++++++++- .../apigateway/next_gen/execute_api/router.py | 31 +++++++++++++++++++ .../next_gen/execute_api/variables.py | 2 +- .../services/apigateway/next_gen/provider.py | 5 ++- .../apigateway/test_apigateway_canary.py | 1 - 8 files changed, 74 insertions(+), 16 deletions(-) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py index 932eacee71048..a19f574c26cac 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py @@ -5,7 +5,7 @@ from rolo.gateway import RequestContext from werkzeug.datastructures import Headers -from localstack.aws.api.apigateway import Integration, Method, Resource +from localstack.aws.api.apigateway import Integration, Method, Resource, Stage from localstack.services.apigateway.models import RestApiDeployment from .variables import ContextVariableOverrides, ContextVariables, LoggingContextVariables @@ -79,7 +79,7 @@ class RestApiInvocationContext(RequestContext): api_id: Optional[str] """The REST API identifier of the invoked API""" stage: Optional[str] - """The REST API stage linked to this invocation""" + """The REST API stage name linked to this invocation""" base_path: Optional[str] """The REST API base path mapped to the stage of this invocation""" deployment_id: Optional[str] @@ -96,6 +96,10 @@ class RestApiInvocationContext(RequestContext): """The method of the resource the invocation matched""" stage_variables: Optional[dict[str, str]] """The Stage variables, also used in parameters mapping and mapping templates""" + stage_configuration: Optional[Stage] + """The Stage configuration, containing canary deployment settings""" + is_canary: bool = False + """If the current call was directed to a canary deployment""" context_variables: Optional[ContextVariables] """The $context used in data models, authorizers, mapping templates, and CloudWatch access logging""" context_variable_overrides: Optional[ContextVariableOverrides] @@ -126,6 +130,7 @@ def __init__(self, request: Request): self.resource_method = None self.integration = None self.stage_variables = None + self.stage_configuration = None self.context_variables = None self.logging_context_variables = None self.integration_request = None diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py index 829314807752d..43bea5ef33882 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py @@ -17,7 +17,6 @@ from ..context import InvocationRequest, RestApiInvocationContext from ..header_utils import should_drop_header_from_invocation from ..helpers import generate_trace_id, generate_trace_parent, parse_trace_id -from ..moto_helpers import get_stage_variables from ..variables import ( ContextVariableOverrides, ContextVariables, @@ -53,7 +52,7 @@ def parse_and_enrich(self, context: RestApiInvocationContext): # TODO: maybe adjust the logging LOG.debug("Initializing $context='%s'", context.context_variables) # then populate the stage variables - context.stage_variables = self.fetch_stage_variables(context) + context.stage_variables = self.get_stage_variables(context) LOG.debug("Initializing $stageVariables='%s'", context.stage_variables) context.trace_id = self.populate_trace_id(context.request.headers) @@ -172,19 +171,20 @@ def create_context_variables(context: RestApiInvocationContext) -> ContextVariab requestTime=timestamp(time=now, format=REQUEST_TIME_DATE_FORMAT), requestTimeEpoch=int(now.timestamp() * 1000), stage=context.stage, + isCanaryRequest=context.is_canary, ) return context_variables @staticmethod - def fetch_stage_variables(context: RestApiInvocationContext) -> Optional[dict[str, str]]: - stage_variables = get_stage_variables( - account_id=context.account_id, - region=context.region, - api_id=context.api_id, - stage_name=context.stage, - ) + def get_stage_variables(context: RestApiInvocationContext) -> Optional[dict[str, str]]: + stage_variables = context.stage_configuration.get("variables") + if context.is_canary: + overrides = ( + context.stage_configuration["canarySettings"].get("stageVariableOverrides") or {} + ) + stage_variables = (stage_variables or {}) | overrides + if not stage_variables: - # we need to set the stage variables to None in the context if we don't have at least one return None return stage_variables diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py index 06c249f5fb0e8..33999b69ea1a9 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py @@ -1,5 +1,6 @@ import copy import logging +import random import re import time from secrets import token_hex @@ -174,3 +175,9 @@ def mime_type_matches_binary_media_types(mime_type: str | None, binary_media_typ return True return False + + +def should_divert_to_canary(percent_traffic: float) -> bool: + if int(percent_traffic) == 100: + return True + return percent_traffic > random.random() * 100 diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py index ae9e9ddc6a7a2..d54b25b560759 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py @@ -1,7 +1,13 @@ from moto.apigateway.models import APIGatewayBackend, apigateway_backends from moto.apigateway.models import RestAPI as MotoRestAPI -from localstack.aws.api.apigateway import ApiKey, ListOfUsagePlan, ListOfUsagePlanKey, Resource +from localstack.aws.api.apigateway import ( + ApiKey, + ListOfUsagePlan, + ListOfUsagePlanKey, + Resource, + Stage, +) def get_resources_from_moto_rest_api(moto_rest_api: MotoRestAPI) -> dict[str, Resource]: @@ -40,6 +46,13 @@ def get_stage_variables( return stage.variables +def get_stage_configuration(account_id: str, region: str, api_id: str, stage_name: str) -> Stage: + apigateway_backend: APIGatewayBackend = apigateway_backends[account_id][region] + moto_rest_api = apigateway_backend.get_rest_api(api_id) + stage = moto_rest_api.stages[stage_name] + return stage.to_json() + + def get_usage_plans(account_id: str, region_name: str) -> ListOfUsagePlan: """ Will return a list of usage plans from the moto store. diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py index 93f509b8aed88..8bd1a56fded3b 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py @@ -5,6 +5,7 @@ from rolo.routing.handler import Handler from werkzeug.routing import Rule +from localstack.aws.api.apigateway import Stage from localstack.constants import APPLICATION_JSON, AWS_REGION_US_EAST_1, DEFAULT_AWS_ACCOUNT_ID from localstack.deprecations import deprecated_endpoint from localstack.http import Response @@ -14,6 +15,8 @@ from .context import RestApiInvocationContext from .gateway import RestApiGateway +from .helpers import should_divert_to_canary +from .moto_helpers import get_stage_configuration LOG = logging.getLogger(__name__) @@ -88,11 +91,39 @@ def populate_rest_api_invocation_context( # TODO: find proper error when trying to hit an API with no deployment/stage linked return + stage_configuration = self.fetch_stage_configuration( + account_id=frozen_deployment.account_id, + region=frozen_deployment.region, + api_id=api_id, + stage_name=stage, + ) + if canary_settings := stage_configuration.get("canarySettings"): + if should_divert_to_canary(canary_settings["percentTraffic"]): + deployment_id = canary_settings["deploymentId"] + frozen_deployment = self._global_store.internal_deployments[api_id][deployment_id] + context.is_canary = True + context.deployment = frozen_deployment context.api_id = api_id context.stage = stage + context.stage_configuration = stage_configuration context.deployment_id = deployment_id + @staticmethod + def fetch_stage_configuration( + account_id: str, region: str, api_id: str, stage_name: str + ) -> Stage: + # this will be migrated once we move away from Moto, so we won't need the helper anymore and the logic will + # be implemented here + stage_variables = get_stage_configuration( + account_id=account_id, + region=region, + api_id=api_id, + stage_name=stage_name, + ) + + return stage_variables + @staticmethod def create_response(request: Request) -> Response: # Creates a default apigw response. diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py index 76d5a40b18710..e457c61180353 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py @@ -112,7 +112,7 @@ class ContextVariables(TypedDict, total=False): httpMethod: str """The HTTP method used""" identity: Optional[ContextVarsIdentity] - isCanaryRequest: Optional[bool | str] # TODO: verify type + isCanaryRequest: Optional[bool] """Indicates if the request was directed to the canary""" path: str """The request path.""" diff --git a/localstack-core/localstack/services/apigateway/next_gen/provider.py b/localstack-core/localstack/services/apigateway/next_gen/provider.py index f98bb9ef4a593..5153463c60a4c 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/provider.py +++ b/localstack-core/localstack/services/apigateway/next_gen/provider.py @@ -216,6 +216,9 @@ def update_stage( "useStageCache": False, } default_canary_settings.update(canary_settings) + default_canary_settings["percentTraffic"] = float( + default_canary_settings["percentTraffic"] + ) moto_stage_copy.canary_settings = default_canary_settings moto_rest_api.stages[stage_name] = moto_stage_copy @@ -291,7 +294,6 @@ def create_deployment( if stage_name: moto_stage = moto_rest_api.stages[stage_name] - store.active_deployments.setdefault(router_api_id, {})[stage_name] = deployment_id if canary_settings: moto_stage = current_stage moto_rest_api.stages[stage_name] = current_stage @@ -304,6 +306,7 @@ def create_deployment( default_settings.update(canary_settings) moto_stage.canary_settings = default_settings else: + store.active_deployments.setdefault(router_api_id, {})[stage_name] = deployment_id moto_stage.canary_settings = None if variables: diff --git a/tests/aws/services/apigateway/test_apigateway_canary.py b/tests/aws/services/apigateway/test_apigateway_canary.py index fc64496bd62c7..23c2ae075ed16 100644 --- a/tests/aws/services/apigateway/test_apigateway_canary.py +++ b/tests/aws/services/apigateway/test_apigateway_canary.py @@ -589,7 +589,6 @@ def test_update_stage_with_copy_ops( snapshot.match("update-stage-with-copy-2", update_stage_2) -@pytest.mark.skip(reason="Not yet implemented") class TestCanaryDeployments: @markers.aws.validated def test_invoking_canary_deployment(self, aws_client, create_api_for_deployment, snapshot): From ea5ecbf214b23685a2126fc4586bc2e93838d3c1 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Sat, 31 May 2025 02:40:05 +0200 Subject: [PATCH 2/3] small fix in UpdateIntegrationResponse --- .../localstack/services/apigateway/legacy/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localstack-core/localstack/services/apigateway/legacy/provider.py b/localstack-core/localstack/services/apigateway/legacy/provider.py index dc3d968d8e4f7..084108eaf2e0c 100644 --- a/localstack-core/localstack/services/apigateway/legacy/provider.py +++ b/localstack-core/localstack/services/apigateway/legacy/provider.py @@ -622,7 +622,7 @@ def update_integration_response( param = param.replace("~1", "/") if op == "remove": integration_response.response_templates.pop(param) - elif op == "add": + elif op in ("add", "replace"): integration_response.response_templates[param] = value elif "/contentHandling" in path and op == "replace": From 1239e1bf9e3d7f9414657cc8a2652a94696b26a7 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Sat, 31 May 2025 03:16:04 +0200 Subject: [PATCH 3/3] fix isCanaryRequest optionality + unit tests --- .../apigateway/next_gen/execute_api/context.py | 3 ++- .../apigateway/next_gen/execute_api/handlers/parse.py | 4 +++- .../apigateway/next_gen/execute_api/router.py | 2 ++ .../unit/services/apigateway/test_handler_request.py | 11 ++++++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py index a19f574c26cac..9f6be795d9af8 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py @@ -98,7 +98,7 @@ class RestApiInvocationContext(RequestContext): """The Stage variables, also used in parameters mapping and mapping templates""" stage_configuration: Optional[Stage] """The Stage configuration, containing canary deployment settings""" - is_canary: bool = False + is_canary: Optional[bool] """If the current call was directed to a canary deployment""" context_variables: Optional[ContextVariables] """The $context used in data models, authorizers, mapping templates, and CloudWatch access logging""" @@ -131,6 +131,7 @@ def __init__(self, request: Request): self.integration = None self.stage_variables = None self.stage_configuration = None + self.is_canary = None self.context_variables = None self.logging_context_variables = None self.integration_request = None diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py index 43bea5ef33882..3da898bf8845e 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py @@ -171,8 +171,10 @@ def create_context_variables(context: RestApiInvocationContext) -> ContextVariab requestTime=timestamp(time=now, format=REQUEST_TIME_DATE_FORMAT), requestTimeEpoch=int(now.timestamp() * 1000), stage=context.stage, - isCanaryRequest=context.is_canary, ) + if context.is_canary is not None: + context_variables["isCanaryRequest"] = context.is_canary + return context_variables @staticmethod diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py index 8bd1a56fded3b..6c0ca3245164b 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py @@ -102,6 +102,8 @@ def populate_rest_api_invocation_context( deployment_id = canary_settings["deploymentId"] frozen_deployment = self._global_store.internal_deployments[api_id][deployment_id] context.is_canary = True + else: + context.is_canary = False context.deployment = frozen_deployment context.api_id = api_id diff --git a/tests/unit/services/apigateway/test_handler_request.py b/tests/unit/services/apigateway/test_handler_request.py index 50ab57dde4147..1aec3d05e32a7 100644 --- a/tests/unit/services/apigateway/test_handler_request.py +++ b/tests/unit/services/apigateway/test_handler_request.py @@ -20,6 +20,7 @@ freeze_rest_api, parse_trace_id, ) +from localstack.services.apigateway.next_gen.execute_api.moto_helpers import get_stage_configuration from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME TEST_API_ID = "testapi" @@ -64,6 +65,12 @@ def _create_context(request: Request) -> RestApiInvocationContext: context.stage = TEST_API_STAGE context.account_id = TEST_AWS_ACCOUNT_ID context.region = TEST_AWS_REGION_NAME + context.stage_configuration = get_stage_configuration( + account_id=TEST_AWS_ACCOUNT_ID, + region=TEST_AWS_REGION_NAME, + api_id=TEST_API_ID, + stage_name=TEST_API_STAGE, + ) return context return _create_context @@ -72,7 +79,9 @@ def _create_context(request: Request) -> RestApiInvocationContext: @pytest.fixture def parse_handler_chain() -> RestApiGatewayHandlerChain: """Returns a dummy chain for testing.""" - return RestApiGatewayHandlerChain(request_handlers=[InvocationRequestParser()]) + chain = RestApiGatewayHandlerChain(request_handlers=[InvocationRequestParser()]) + chain.raise_on_error = True + return chain class TestParsingHandler: