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

Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

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
F438 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