Skip to content

Commit c3c04dd

Browse files
hoffaxazhao
andauthored
feat: add AlwaysDeploy to AWS::Serverless::Api (#2935)
Co-authored-by: Xia Zhao <[email protected]>
1 parent 1350915 commit c3c04dd

File tree

10 files changed

+140
-10
lines changed

10 files changed

+140
-10
lines changed

samtranslator/internal/schema_source/aws_serverless_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ class EndpointConfiguration(BaseModel):
172172
CanarySetting = Optional[PassThroughProp]
173173
TracingEnabled = Optional[PassThroughProp]
174174
OpenApiVersion = Optional[Union[float, str]] # TODO: float doesn't exist in documentation
175+
AlwaysDeploy = Optional[bool]
175176

176177

177178
class Properties(BaseModel):
@@ -202,6 +203,7 @@ class Properties(BaseModel):
202203
Tags: Optional[DictStrAny] = properties("Tags")
203204
TracingEnabled: Optional[TracingEnabled] = properties("TracingEnabled")
204205
Variables: Optional[Variables] = properties("Variables")
206+
AlwaysDeploy: Optional[AlwaysDeploy] # TODO: Add docs
205207

206208

207209
class Globals(BaseModel):
@@ -223,6 +225,7 @@ class Globals(BaseModel):
223225
TracingEnabled: Optional[TracingEnabled] = properties("TracingEnabled")
224226
OpenApiVersion: Optional[OpenApiVersion] = properties("OpenApiVersion")
225227
Domain: Optional[Domain] = properties("Domain")
228+
AlwaysDeploy: Optional[AlwaysDeploy] # TODO: Add docs
226229

227230

228231
class Resource(ResourceAttributes):

samtranslator/model/api/api_generator.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def __init__( # noqa: too-many-arguments
194194
description: Optional[Intrinsicable[str]] = None,
195195
mode: Optional[Intrinsicable[str]] = None,
196196
api_key_source_type: Optional[Intrinsicable[str]] = None,
197+
always_deploy: Optional[bool] = False,
197198
):
198199
"""Constructs an API Generator class that generates API Gateway resources
199200
@@ -249,6 +250,7 @@ def __init__( # noqa: too-many-arguments
249250
self.template_conditions = template_conditions
250251
self.mode = mode
251252
self.api_key_source_type = api_key_source_type
253+
self.always_deploy = always_deploy
252254

253255
def _construct_rest_api(self) -> ApiGatewayRestApi:
254256
"""Constructs and returns the ApiGateway RestApi.
@@ -425,7 +427,12 @@ def _construct_stage(
425427

426428
if swagger is not None:
427429
deployment.make_auto_deployable(
428-
stage, self.remove_extra_stage, swagger, self.domain, redeploy_restapi_parameters
430+
stage,
431+
self.remove_extra_stage,
432+
swagger,
433+
self.domain,
434+
redeploy_restapi_parameters,
435+
self.always_deploy,
429436
)
430437

431438
if self.tags is not None:

samtranslator/model/apigateway.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import time
23
from re import match
34
from typing import Any, Dict, List, Optional, Union
45

@@ -89,16 +90,17 @@ class ApiGatewayDeployment(Resource):
8990

9091
runtime_attrs = {"deployment_id": lambda self: ref(self.logical_id)}
9192

92-
def make_auto_deployable(
93+
def make_auto_deployable( # noqa: too-many-arguments
9394
self,
9495
stage: ApiGatewayStage,
9596
openapi_version: Optional[Union[Dict[str, Any], str]] = None,
9697
swagger: Optional[Dict[str, Any]] = None,
9798
domain: Optional[Dict[str, Any]] = None,
9899
redeploy_restapi_parameters: Optional[Any] = None,
100+
always_deploy: Optional[bool] = False,
99101
) -> None:
100102
"""
101-
Sets up the resource such that it will trigger a re-deployment when Swagger changes
103+
Sets up the resource such that it will trigger a re-deployment when Swagger changes or always_deploy is true
102104
or the openapi version changes or a domain resource changes.
103105
104106
:param stage: The ApiGatewayStage object which will be re-deployed
@@ -126,6 +128,10 @@ def make_auto_deployable(
126128
# The keyword "Deployment" is removed and all the function names associated with api is obtained
127129
if function_names and function_names.get(self.logical_id[:-10], None):
128130
hash_input.append(function_names.get(self.logical_id[:-10], ""))
131+
if always_deploy:
132+
# We just care that the hash changes every time
133+
# Using int so tests are a little more robust; don't think the Python spec defines default precision
134+
hash_input = [str(int(time.time()))]
129135
data = self._X_HASH_DELIMITER.join(hash_input)
130136
generator = logical_id_generator.LogicalIdGenerator(self.logical_id, data)
131137
self.logical_id = generator.gen()

samtranslator/model/sam_resources.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,7 @@ class SamApi(SamResourceMacro):
11681168
"Mode": PropertyType(False, IS_STR),
11691169
"DisableExecuteApiEndpoint": PropertyType(False, is_type(bool)),
11701170
"ApiKeySourceType": PropertyType(False, IS_STR),
1171+
"AlwaysDeploy": Property(False, is_type(bool)),
11711172
}
11721173

11731174
Name: Optional[Intrinsicable[str]]
@@ -1197,6 +1198,7 @@ class SamApi(SamResourceMacro):
11971198
Mode: Optional[Intrinsicable[str]]
11981199
DisableExecuteApiEndpoint: Optional[Intrinsicable[bool]]
11991200
ApiKeySourceType: Optional[Intrinsicable[str]]
1201+
AlwaysDeploy: Optional[bool]
12001202

12011203
referable_properties = {
12021204
"Stage": ApiGatewayStage.resource_type,
@@ -1261,6 +1263,7 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def]
12611263
description=self.Description,
12621264
mode=self.Mode,
12631265
api_key_source_type=self.ApiKeySourceType,
1266+
always_deploy=self.AlwaysDeploy,
12641267
)
12651268

12661269
(

samtranslator/plugins/globals/globals.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class Globals:
7575
"TracingEnabled",
7676
"OpenApiVersion",
7777
"Domain",
78+
"AlwaysDeploy",
7879
],
7980
SamResourceType.HttpApi.value: [
8081
"Auth",

samtranslator/schema/schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196838,6 +196838,10 @@
196838196838
"markdownDescription": "Configures Access Log Setting for a stage\\. \n*Type*: [AccessLogSetting](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is passed directly to the [`AccessLogSetting`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) property of an `AWS::ApiGateway::Stage` resource\\.",
196839196839
"title": "AccessLogSetting"
196840196840
},
196841+
"AlwaysDeploy": {
196842+
"title": "Alwaysdeploy",
196843+
"type": "boolean"
196844+
},
196841196845
"Auth": {
196842196846
"allOf": [
196843196847
{
@@ -197024,6 +197028,10 @@
197024197028
"markdownDescription": "Configures Access Log Setting for a stage\\. \n*Type*: [AccessLogSetting](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is passed directly to the [`AccessLogSetting`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) property of an `AWS::ApiGateway::Stage` resource\\.",
197025197029
"title": "AccessLogSetting"
197026197030
},
197031+
"AlwaysDeploy": {
197032+
"title": "Alwaysdeploy",
197033+
"type": "boolean"
197034+
},
197027197035
"ApiKeySourceType": {
197028197036
"allOf": [
197029197037
{

schema_source/sam.schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3237,6 +3237,10 @@
32373237
"markdownDescription": "Configures Access Log Setting for a stage\\. \n*Type*: [AccessLogSetting](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is passed directly to the [`AccessLogSetting`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) property of an `AWS::ApiGateway::Stage` resource\\.",
32383238
"title": "AccessLogSetting"
32393239
},
3240+
"AlwaysDeploy": {
3241+
"title": "Alwaysdeploy",
3242+
"type": "boolean"
3243+
},
32403244
"Auth": {
32413245
"allOf": [
32423246
{
@@ -3423,6 +3427,10 @@
34233427
"markdownDescription": "Configures Access Log Setting for a stage\\. \n*Type*: [AccessLogSetting](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is passed directly to the [`AccessLogSetting`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html#cfn-apigateway-stage-accesslogsetting) property of an `AWS::ApiGateway::Stage` resource\\.",
34243428
"title": "AccessLogSetting"
34253429
},
3430+
"AlwaysDeploy": {
3431+
"title": "Alwaysdeploy",
3432+
"type": "boolean"
3433+
},
34263434
"ApiKeySourceType": {
34273435
"allOf": [
34283436
{
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Globals:
2+
Api:
3+
AlwaysDeploy: true
4+
Resources:
5+
MyApi:
6+
Type: AWS::Serverless::Api
7+
Properties:
8+
StageName: MyStage

tests/translator/output/error_globals_api_with_stage_name.json

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,7 @@
44
"Number of errors found: 1. ",
55
"'Globals' section is invalid. ",
66
"'StageName' is not a supported property of 'Api'. ",
7-
"Must be one of the following values - ['Auth', 'Name', 'DefinitionUri', 'CacheClusterEnabled', 'CacheClusterSize', 'MergeDefinitions', 'Variables', 'EndpointConfiguration', 'MethodSettings', 'BinaryMediaTypes', 'MinimumCompressionSize', 'Cors', 'GatewayResponses', 'AccessLogSetting', 'CanarySetting', 'TracingEnabled', 'OpenApiVersion', 'Domain']"
7+
"Must be one of the following values - ['Auth', 'Name', 'DefinitionUri', 'CacheClusterEnabled', 'CacheClusterSize', 'MergeDefinitions', 'Variables', 'EndpointConfiguration', 'MethodSettings', 'BinaryMediaTypes', 'MinimumCompressionSize', 'Cors', 'GatewayResponses', 'AccessLogSetting', 'CanarySetting', 'TracingEnabled', 'OpenApiVersion', 'Domain', 'AlwaysDeploy']"
88
],
9-
"errorMessage": "Invalid Serverless Application Specification document. Number of errors found: 1. 'Globals' section is invalid. 'StageName' is not a supported property of 'Api'. Must be one of the following values - ['Auth', 'Name', 'DefinitionUri', 'CacheClusterEnabled', 'CacheClusterSize', 'MergeDefinitions', 'Variables', 'EndpointConfiguration', 'MethodSettings', 'BinaryMediaTypes', 'MinimumCompressionSize', 'Cors', 'GatewayResponses', 'AccessLogSetting', 'CanarySetting', 'TracingEnabled', 'OpenApiVersion', 'Domain']",
10-
"errors": [
11-
{
12-
"errorMessage": "'Globals' section is invalid. 'StageName' is not a supported property of 'Api'. Must be one of the following values - ['Auth', 'Name', 'DefinitionUri', 'CacheClusterEnabled', 'CacheClusterSize', 'Variables', 'EndpointConfiguration', 'MethodSettings', 'BinaryMediaTypes', 'Cors', 'GatewayResponses', 'AccessLogSetting', 'CanarySetting', 'OpenApiVersion', 'Domain']"
13-
}
14-
]
9+
"errorMessage": "Invalid Serverless Application Specification document. Number of errors found: 1. 'Globals' section is invalid. 'StageName' is not a supported property of 'Api'. Must be one of the following values - ['Auth', 'Name', 'DefinitionUri', 'CacheClusterEnabled', 'CacheClusterSize', 'MergeDefinitions', 'Variables', 'EndpointConfiguration', 'MethodSettings', 'BinaryMediaTypes', 'MinimumCompressionSize', 'Cors', 'GatewayResponses', 'AccessLogSetting', 'CanarySetting', 'TracingEnabled', 'OpenApiVersion', 'Domain', 'AlwaysDeploy']"
1510
}

tests/translator/test_translator.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import json
44
import os.path
55
import re
6+
import time
67
from functools import cmp_to_key, reduce
8+
from pathlib import Path
79
from unittest import TestCase
810
from unittest.mock import MagicMock, Mock, patch
911

@@ -21,6 +23,7 @@
2123
from tests.plugins.application.test_serverless_app_plugin import mock_get_region
2224
from tests.translator.helpers import get_template_parameter_values
2325

26+
PROJECT_ROOT = Path(__file__).parent.parent.parent
2427
BASE_PATH = os.path.dirname(__file__)
2528
INPUT_FOLDER = BASE_PATH + "/input"
2629
OUTPUT_FOLDER = BASE_PATH + "/output"
@@ -37,6 +40,10 @@
3740
OUTPUT_FOLDER = os.path.join(BASE_PATH, "output")
3841

3942

43+
def _parse_yaml(path):
44+
return yaml_parse(PROJECT_ROOT.joinpath(path).read_text())
45+
46+
4047
def deep_sort_lists(value):
4148
"""
4249
Custom sorting implemented as a wrapper on top of Python's built-in ``sorted`` method. This is necessary because
@@ -657,6 +664,90 @@ def _do_transform(self, document, parameter_values=get_template_parameter_values
657664
return output_fragment
658665

659666

667+
class TestApiAlwaysDeploy(TestCase):
668+
"""
669+
AlwaysDeploy is used to force API Gateway to redeploy at every deployment.
670+
See https://github.com/aws/serverless-application-model/issues/660
671+
672+
Since it relies on the system time to generate the template, need to patch
673+
time.time() for deterministic tests.
674+
"""
675+
676+
@staticmethod
677+
def get_deployment_ids(template):
678+
cfn_template = Translator({}, Parser()).translate(template, {})
679+
deployment_ids = set()
680+
for k, v in cfn_template["Resources"].items():
681+
if v["Type"] == "AWS::ApiGateway::Deployment":
682+
deployment_ids.add(k)
683+
return deployment_ids
684+
685+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
686+
@patch("botocore.client.ClientEndpointBridge._check_default_region", mock_get_region)
687+
def test_always_deploy(self):
688+
with patch("time.time", lambda: 13.37):
689+
obj = _parse_yaml("tests/translator/input/translate_always_deploy.yaml")
690+
deployment_ids = TestApiAlwaysDeploy.get_deployment_ids(obj)
691+
self.assertEqual(deployment_ids, {"MyApiDeploymentbd307a3ec3"})
692+
693+
with patch("time.time", lambda: 42.123):
694+
obj = _parse_yaml("tests/translator/input/translate_always_deploy.yaml")
695+
deployment_ids = TestApiAlwaysDeploy.get_deployment_ids(obj)
696+
self.assertEqual(deployment_ids, {"MyApiDeployment92cfceb39d"})
697+
698+
with patch("time.time", lambda: 42.1337):
699+
obj = _parse_yaml("tests/translator/input/translate_always_deploy.yaml")
700+
deployment_ids = TestApiAlwaysDeploy.get_deployment_ids(obj)
701+
self.assertEqual(deployment_ids, {"MyApiDeployment92cfceb39d"})
702+
703+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
704+
@patch("botocore.client.ClientEndpointBridge._check_default_region", mock_get_region)
705+
def test_without_alwaysdeploy_never_changes(self):
706+
sam_template = {
707+
"Resources": {
708+
"MyApi": {
709+
"Type": "AWS::Serverless::Api",
710+
"Properties": {
711+
"StageName": "prod",
712+
},
713+
}
714+
},
715+
}
716+
717+
deployment_ids = set()
718+
deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template))
719+
time.sleep(2)
720+
deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template))
721+
time.sleep(2)
722+
deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template))
723+
724+
self.assertEqual(len(deployment_ids), 1)
725+
726+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
727+
@patch("botocore.client.ClientEndpointBridge._check_default_region", mock_get_region)
728+
def test_with_alwaysdeploy_always_changes(self):
729+
sam_template = {
730+
"Resources": {
731+
"MyApi": {
732+
"Type": "AWS::Serverless::Api",
733+
"Properties": {
734+
"StageName": "prod",
735+
"AlwaysDeploy": True,
736+
},
737+
}
738+
},
739+
}
740+
741+
deployment_ids = set()
742+
deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template))
743+
time.sleep(2)
744+
deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template))
745+
time.sleep(2)
746+
deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template))
747+
748+
self.assertEqual(len(deployment_ids), 3)
749+
750+
660751
class TestTemplateValidation(TestCase):
661752
_MANAGED_POLICIES_TEMPLATE = {
662753
"Resources": {

0 commit comments

Comments
 (0)