Skip to content

Commit

Permalink
feat: add AlwaysDeploy to AWS::Serverless::Api (#2935)
Browse files Browse the repository at this point in the history
Co-authored-by: Xia Zhao <[email protected]>
  • Loading branch information
hoffa and xazhao authored Feb 28, 2023
1 parent 1350915 commit c3c04dd
Show file tree
Hide file tree
Showing 10 changed files with 140 additions and 10 deletions.
3 changes: 3 additions & 0 deletions samtranslator/internal/schema_source/aws_serverless_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion samtranslator/model/api/api_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions samtranslator/model/apigateway.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import time
from re import match
from typing import Any, Dict, List, Optional, Union

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions samtranslator/model/sam_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)

(
Expand Down
1 change: 1 addition & 0 deletions samtranslator/plugins/globals/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class Globals:
"TracingEnabled",
"OpenApiVersion",
"Domain",
"AlwaysDeploy",
],
SamResourceType.HttpApi.value: [
"Auth",
Expand Down
8 changes: 8 additions & 0 deletions samtranslator/schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [
{
Expand Down
8 changes: 8 additions & 0 deletions schema_source/sam.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [
{
Expand Down
8 changes: 8 additions & 0 deletions tests/translator/input/translate_always_deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Globals:
Api:
AlwaysDeploy: true
Resources:
MyApi:
Type: AWS::Serverless::Api
Properties:
StageName: MyStage
Original file line number Diff line number Diff line change
Expand Up @@ -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']"
}
91 changes: 91 additions & 0 deletions tests/translator/test_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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": {
Expand Down

0 comments on commit c3c04dd

Please sign in to comment.