From c3c04dd283786b3b29f4f10bb4a8208fa4bad327 Mon Sep 17 00:00:00 2001 From: Christoffer Rehn <1280602+hoffa@users.noreply.github.com> Date: Tue, 28 Feb 2023 11:45:08 -0800 Subject: [PATCH] feat: add `AlwaysDeploy` to `AWS::Serverless::Api` (#2935) Co-authored-by: Xia Zhao <78883180+xazhao@users.noreply.github.com> --- .../schema_source/aws_serverless_api.py | 3 + samtranslator/model/api/api_generator.py | 9 +- samtranslator/model/apigateway.py | 10 +- samtranslator/model/sam_resources.py | 3 + samtranslator/plugins/globals/globals.py | 1 + samtranslator/schema/schema.json | 8 ++ schema_source/sam.schema.json | 8 ++ .../input/translate_always_deploy.yaml | 8 ++ .../error_globals_api_with_stage_name.json | 9 +- tests/translator/test_translator.py | 91 +++++++++++++++++++ 10 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 tests/translator/input/translate_always_deploy.yaml diff --git a/samtranslator/internal/schema_source/aws_serverless_api.py b/samtranslator/internal/schema_source/aws_serverless_api.py index 5e3965853..e43e9242d 100644 --- a/samtranslator/internal/schema_source/aws_serverless_api.py +++ b/samtranslator/internal/schema_source/aws_serverless_api.py @@ -172,6 +172,7 @@ class EndpointConfiguration(BaseModel): CanarySetting = Optional[PassThroughProp] TracingEnabled = Optional[PassThroughProp] OpenApiVersion = Optional[Union[float, str]] # TODO: float doesn't exist in documentation +AlwaysDeploy = Optional[bool] class Properties(BaseModel): @@ -202,6 +203,7 @@ class Properties(BaseModel): Tags: Optional[DictStrAny] = properties("Tags") TracingEnabled: Optional[TracingEnabled] = properties("TracingEnabled") Variables: Optional[Variables] = properties("Variables") + AlwaysDeploy: Optional[AlwaysDeploy] # TODO: Add docs class Globals(BaseModel): @@ -223,6 +225,7 @@ class Globals(BaseModel): TracingEnabled: Optional[TracingEnabled] = properties("TracingEnabled") OpenApiVersion: Optional[OpenApiVersion] = properties("OpenApiVersion") Domain: Optional[Domain] = properties("Domain") + AlwaysDeploy: Optional[AlwaysDeploy] # TODO: Add docs class Resource(ResourceAttributes): diff --git a/samtranslator/model/api/api_generator.py b/samtranslator/model/api/api_generator.py index 23972e336..5c84da483 100644 --- a/samtranslator/model/api/api_generator.py +++ b/samtranslator/model/api/api_generator.py @@ -194,6 +194,7 @@ def __init__( # noqa: too-many-arguments description: Optional[Intrinsicable[str]] = None, mode: Optional[Intrinsicable[str]] = None, api_key_source_type: Optional[Intrinsicable[str]] = None, + always_deploy: Optional[bool] = False, ): """Constructs an API Generator class that generates API Gateway resources @@ -249,6 +250,7 @@ def __init__( # noqa: too-many-arguments self.template_conditions = template_conditions self.mode = mode self.api_key_source_type = api_key_source_type + self.always_deploy = always_deploy def _construct_rest_api(self) -> ApiGatewayRestApi: """Constructs and returns the ApiGateway RestApi. @@ -425,7 +427,12 @@ def _construct_stage( if swagger is not None: deployment.make_auto_deployable( - stage, self.remove_extra_stage, swagger, self.domain, redeploy_restapi_parameters + stage, + self.remove_extra_stage, + swagger, + self.domain, + redeploy_restapi_parameters, + self.always_deploy, ) if self.tags is not None: diff --git a/samtranslator/model/apigateway.py b/samtranslator/model/apigateway.py index 74b68bfa8..6cad03ba7 100644 --- a/samtranslator/model/apigateway.py +++ b/samtranslator/model/apigateway.py @@ -1,4 +1,5 @@ import json +import time from re import match from typing import Any, Dict, List, Optional, Union @@ -89,16 +90,17 @@ class ApiGatewayDeployment(Resource): runtime_attrs = {"deployment_id": lambda self: ref(self.logical_id)} - def make_auto_deployable( + def make_auto_deployable( # noqa: too-many-arguments self, stage: ApiGatewayStage, openapi_version: Optional[Union[Dict[str, Any], str]] = None, swagger: Optional[Dict[str, Any]] = None, domain: Optional[Dict[str, Any]] = None, redeploy_restapi_parameters: Optional[Any] = None, + always_deploy: Optional[bool] = False, ) -> None: """ - Sets up the resource such that it will trigger a re-deployment when Swagger changes + Sets up the resource such that it will trigger a re-deployment when Swagger changes or always_deploy is true or the openapi version changes or a domain resource changes. :param stage: The ApiGatewayStage object which will be re-deployed @@ -126,6 +128,10 @@ def make_auto_deployable( # The keyword "Deployment" is removed and all the function names associated with api is obtained if function_names and function_names.get(self.logical_id[:-10], None): hash_input.append(function_names.get(self.logical_id[:-10], "")) + if always_deploy: + # We just care that the hash changes every time + # Using int so tests are a little more robust; don't think the Python spec defines default precision + hash_input = [str(int(time.time()))] data = self._X_HASH_DELIMITER.join(hash_input) generator = logical_id_generator.LogicalIdGenerator(self.logical_id, data) self.logical_id = generator.gen() diff --git a/samtranslator/model/sam_resources.py b/samtranslator/model/sam_resources.py index 0f140b2d3..a548bff28 100644 --- a/samtranslator/model/sam_resources.py +++ b/samtranslator/model/sam_resources.py @@ -1168,6 +1168,7 @@ class SamApi(SamResourceMacro): "Mode": PropertyType(False, IS_STR), "DisableExecuteApiEndpoint": PropertyType(False, is_type(bool)), "ApiKeySourceType": PropertyType(False, IS_STR), + "AlwaysDeploy": Property(False, is_type(bool)), } Name: Optional[Intrinsicable[str]] @@ -1197,6 +1198,7 @@ class SamApi(SamResourceMacro): Mode: Optional[Intrinsicable[str]] DisableExecuteApiEndpoint: Optional[Intrinsicable[bool]] ApiKeySourceType: Optional[Intrinsicable[str]] + AlwaysDeploy: Optional[bool] referable_properties = { "Stage": ApiGatewayStage.resource_type, @@ -1261,6 +1263,7 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] description=self.Description, mode=self.Mode, api_key_source_type=self.ApiKeySourceType, + always_deploy=self.AlwaysDeploy, ) ( diff --git a/samtranslator/plugins/globals/globals.py b/samtranslator/plugins/globals/globals.py index f1a0697cc..a0b0b0735 100644 --- a/samtranslator/plugins/globals/globals.py +++ b/samtranslator/plugins/globals/globals.py @@ -75,6 +75,7 @@ class Globals: "TracingEnabled", "OpenApiVersion", "Domain", + "AlwaysDeploy", ], SamResourceType.HttpApi.value: [ "Auth", diff --git a/samtranslator/schema/schema.json b/samtranslator/schema/schema.json index aac6adf31..0544669bf 100644 --- a/samtranslator/schema/schema.json +++ b/samtranslator/schema/schema.json @@ -196838,6 +196838,10 @@ "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\\.", "title": "AccessLogSetting" }, + "AlwaysDeploy": { + "title": "Alwaysdeploy", + "type": "boolean" + }, "Auth": { "allOf": [ { @@ -197024,6 +197028,10 @@ "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\\.", "title": "AccessLogSetting" }, + "AlwaysDeploy": { + "title": "Alwaysdeploy", + "type": "boolean" + }, "ApiKeySourceType": { "allOf": [ { diff --git a/schema_source/sam.schema.json b/schema_source/sam.schema.json index 4ce3c5a09..faf0ba886 100644 --- a/schema_source/sam.schema.json +++ b/schema_source/sam.schema.json @@ -3237,6 +3237,10 @@ "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\\.", "title": "AccessLogSetting" }, + "AlwaysDeploy": { + "title": "Alwaysdeploy", + "type": "boolean" + }, "Auth": { "allOf": [ { @@ -3423,6 +3427,10 @@ "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\\.", "title": "AccessLogSetting" }, + "AlwaysDeploy": { + "title": "Alwaysdeploy", + "type": "boolean" + }, "ApiKeySourceType": { "allOf": [ { diff --git a/tests/translator/input/translate_always_deploy.yaml b/tests/translator/input/translate_always_deploy.yaml new file mode 100644 index 000000000..9177a666e --- /dev/null +++ b/tests/translator/input/translate_always_deploy.yaml @@ -0,0 +1,8 @@ +Globals: + Api: + AlwaysDeploy: true +Resources: + MyApi: + Type: AWS::Serverless::Api + Properties: + StageName: MyStage diff --git a/tests/translator/output/error_globals_api_with_stage_name.json b/tests/translator/output/error_globals_api_with_stage_name.json index 712a930d1..cf689e0f9 100644 --- a/tests/translator/output/error_globals_api_with_stage_name.json +++ b/tests/translator/output/error_globals_api_with_stage_name.json @@ -4,12 +4,7 @@ "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']" + "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']" ], - "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']", - "errors": [ - { - "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']" - } - ] + "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']" } diff --git a/tests/translator/test_translator.py b/tests/translator/test_translator.py index 321074bd7..28173ef32 100644 --- a/tests/translator/test_translator.py +++ b/tests/translator/test_translator.py @@ -3,7 +3,9 @@ import json import os.path import re +import time from functools import cmp_to_key, reduce +from pathlib import Path from unittest import TestCase from unittest.mock import MagicMock, Mock, patch @@ -21,6 +23,7 @@ from tests.plugins.application.test_serverless_app_plugin import mock_get_region from tests.translator.helpers import get_template_parameter_values +PROJECT_ROOT = Path(__file__).parent.parent.parent BASE_PATH = os.path.dirname(__file__) INPUT_FOLDER = BASE_PATH + "/input" OUTPUT_FOLDER = BASE_PATH + "/output" @@ -37,6 +40,10 @@ OUTPUT_FOLDER = os.path.join(BASE_PATH, "output") +def _parse_yaml(path): + return yaml_parse(PROJECT_ROOT.joinpath(path).read_text()) + + def deep_sort_lists(value): """ 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 return output_fragment +class TestApiAlwaysDeploy(TestCase): + """ + AlwaysDeploy is used to force API Gateway to redeploy at every deployment. + See https://github.com/aws/serverless-application-model/issues/660 + + Since it relies on the system time to generate the template, need to patch + time.time() for deterministic tests. + """ + + @staticmethod + def get_deployment_ids(template): + cfn_template = Translator({}, Parser()).translate(template, {}) + deployment_ids = set() + for k, v in cfn_template["Resources"].items(): + if v["Type"] == "AWS::ApiGateway::Deployment": + deployment_ids.add(k) + return deployment_ids + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + @patch("botocore.client.ClientEndpointBridge._check_default_region", mock_get_region) + def test_always_deploy(self): + with patch("time.time", lambda: 13.37): + obj = _parse_yaml("tests/translator/input/translate_always_deploy.yaml") + deployment_ids = TestApiAlwaysDeploy.get_deployment_ids(obj) + self.assertEqual(deployment_ids, {"MyApiDeploymentbd307a3ec3"}) + + with patch("time.time", lambda: 42.123): + obj = _parse_yaml("tests/translator/input/translate_always_deploy.yaml") + deployment_ids = TestApiAlwaysDeploy.get_deployment_ids(obj) + self.assertEqual(deployment_ids, {"MyApiDeployment92cfceb39d"}) + + with patch("time.time", lambda: 42.1337): + obj = _parse_yaml("tests/translator/input/translate_always_deploy.yaml") + deployment_ids = TestApiAlwaysDeploy.get_deployment_ids(obj) + self.assertEqual(deployment_ids, {"MyApiDeployment92cfceb39d"}) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + @patch("botocore.client.ClientEndpointBridge._check_default_region", mock_get_region) + def test_without_alwaysdeploy_never_changes(self): + sam_template = { + "Resources": { + "MyApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "prod", + }, + } + }, + } + + deployment_ids = set() + deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template)) + time.sleep(2) + deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template)) + time.sleep(2) + deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template)) + + self.assertEqual(len(deployment_ids), 1) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + @patch("botocore.client.ClientEndpointBridge._check_default_region", mock_get_region) + def test_with_alwaysdeploy_always_changes(self): + sam_template = { + "Resources": { + "MyApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "prod", + "AlwaysDeploy": True, + }, + } + }, + } + + deployment_ids = set() + deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template)) + time.sleep(2) + deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template)) + time.sleep(2) + deployment_ids.update(TestApiAlwaysDeploy.get_deployment_ids(sam_template)) + + self.assertEqual(len(deployment_ids), 3) + + class TestTemplateValidation(TestCase): _MANAGED_POLICIES_TEMPLATE = { "Resources": {