Thanks to visit codestin.com
Credit goes to github.com

Skip to content

APIGW: add Canary Deployment logic in invocation layer #12695

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What is this fixing? Is it a leftover from the previous pr?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhat, this is because now that the test for the actual logic has been enabled, the test couldn't work because the update_integration_response method was not properly updating...
Here in TestCanaryDeployments.test_invoking_canary_deployment:

        aws_client.apigateway.update_integration_response(
            restApiId=api_id,
            resourceId=resource_id,
            httpMethod="GET",
            statusCode="200",
            patchOperations=[
                {
                    "op": "replace",
                    "path": "/responseTemplates/application~1json",
                    "value": json.dumps(
                        {
                            "statusCode": 200,
                            "message": "canary deployment",
                            "variable": "$stageVariables.testVar",
                            "nonExistingDefault": "$stageVariables.noStageVar",
                            "nonOverridden": "$stageVariables.defaultVar",
                            "isCanary": "$context.isCanaryRequest",
                        }
                    ),
                }
            ],
        )

So I just made that work for now 😅

integration_response.response_templates[param] = value

elif "/contentHandling" in path and op == "replace":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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: 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"""
context_variable_overrides: Optional[ContextVariableOverrides]
Expand Down Expand Up @@ -126,6 +130,8 @@ def __init__(self, request: Request):
self.resource_method = None
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -173,18 +172,21 @@ def create_context_variables(context: RestApiInvocationContext) -> ContextVariab
requestTimeEpoch=int(now.timestamp() * 1000),
stage=context.stage,
)
if context.is_canary is not None:
context_variables["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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
import logging
import random
import re
import time
from secrets import token_hex
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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]:
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -88,11 +91,41 @@ 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
else:
context.is_canary = False

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 0 additions & 1 deletion tests/aws/services/apigateway/test_apigateway_canary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 10 additions & 1 deletion tests/unit/services/apigateway/test_handler_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading