8000 APIGW: add Canary Deployment logic in invocation layer (#12695) · localstack/localstack@bf99fdb · GitHub
[go: up one dir, main page]

Skip to content

Commit bf99fdb

Browse files
authored
APIGW: add Canary Deployment logic in invocation layer (#12695)
1 parent bc653dc commit bf99fdb

File tree

10 files changed

+90
-18
lines changed

10 files changed

+90
-18
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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":

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."""

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ def update_stage(
216216
"useStageCache": False,
217217
}
218218
default_canary_settings.update(canary_settings)
219+
default_canary_settings["percentTraffic"] = float(
220+
default_canary_settings["percentTraffic"]
221+
)
219222
moto_stage_copy.canary_settings = default_canary_settings
220223

221224
moto_rest_api.stages[stage_name] = moto_stage_copy
@@ -291,7 +294,6 @@ def create_deployment(
291294

292295
if stage_name:
293296
moto_stage = moto_rest_api.stages[stage_name]
294-
store.active_deployments.setdefault(router_api_id, {})[stage_name] = deployment_id
295297
if canary_settings:
296298
moto_stage = current_stage
297299
moto_rest_api.stages[stage_name] = current_stage
@@ -304,6 +306,7 @@ def create_deployment(
304306
default_settings.update(canary_settings)
305307
moto_stage.canary_settings = default_settings
306308
else:
309+
store.active_deployments.setdefault(router_api_id, {})[stage_name] = deployment_id
307310
moto_stage.canary_settings = None
308311

309312
if variables:

tests/aws/services/apigateway/test_apigateway_canary.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,6 @@ def test_update_stage_with_copy_ops(
589589
snapshot.match("update-stage-with-copy-2", update_stage_2)
590590

591591

592-
@pytest.mark.skip(reason="Not yet implemented")
593592
class TestCanaryDeployments:
594593
@markers.aws.validated
595594
def test_invoking_canary_deployment(self, aws_client, create_api_for_deployment, snapshot):

tests/unit/services/apigateway/test_handler_request.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
freeze_rest_api,
2121
parse_trace_id,
2222
)
23+
from localstack.services.apigateway.next_gen.execute_api.moto_helpers import get_stage_configuration
2324
from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME
2425

2526
TEST_API_ID = "testapi"
@@ -64,6 +65,12 @@ def _create_context(request: Request) -> RestApiInvocationContext:
6465
context.stage = TEST_API_STAGE
6566
context.account_id = TEST_AWS_ACCOUNT_ID
6667
context.region = TEST_AWS_REGION_NAME
68+
context.stage_configuration = get_stage_configuration(
69+
account_id=TEST_AWS_ACCOUNT_ID,
70+
region=TEST_AWS_REGION_NAME,
71+
api_id=TEST_API_ID,
72+
stage_name=TEST_API_STAGE,
73+
)
6774
return context
6875

6976
return _create_context
@@ -72,7 +79,9 @@ def _create_context(request: Request) -> RestApiInvocationContext:
7279
@pytest.fixture
7380
def parse_handler_chain() -> RestApiGatewayHandlerChain:
7481
"""Returns a dummy chain for testing."""
75-
return RestApiGatewayHandlerChain(request_handlers=[InvocationRequestParser()])
82+
chain = RestApiGatewayHandlerChain(request_handlers=[InvocationRequestParser()])
83+
chain.raise_on_error = True
84+
return chain
7685

7786

7887
class TestParsingHandler:

0 commit comments

Comments
 (0)
0