From 83debbea6e6d8a508440ba9f31a19575bdf337c6 Mon Sep 17 00:00:00 2001 From: Tarek Abdunabi Date: Tue, 7 Dec 2021 13:48:31 -0500 Subject: [PATCH 1/2] Update to version v1.4.1 --- CHANGELOG.md | 14 +++ deployment/build-s3-dist.sh | 2 +- .../lambdas/pipeline_orchestration/index.py | 23 ++-- .../pipeline_orchestration/shared/wrappers.py | 52 +++++---- .../tests/fixtures/orchestrator_fixtures.py | 12 +- .../tests/test_pipeline_orchestration.py | 106 ++++++++++++------ source/lib/aws_mlops_stack.py | 9 ++ .../byom/pipeline_definitions/iam_policies.py | 1 + .../templates_parameters.py | 13 ++- source/requirements.txt | 68 +++++------ 10 files changed, 200 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4e4dd..b871dcd 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.1] - + +### Added + +- Developer section in the Implementation Guide (IG) to explain how customers can integrate + their own custom blueprints with the solution. +- Configurable server-side error propagation to allow/disallow detailed error messages + in the solution's APIs responses. + +### Updated + +- The format of the solution's APIs responses. +- AWS Cloud Development Kit (AWS CDK) and AWS Solutions Constructs to version 1.126.0. + ## [1.4.0] - 2021-09-28 ### Added diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh index 756dfaf..1396517 100755 --- a/deployment/build-s3-dist.sh +++ b/deployment/build-s3-dist.sh @@ -28,7 +28,7 @@ set -e # Important: CDK global version number -cdk_version=1.117.0 +cdk_version=1.126.0 # Check to see if the required parameters have been provided: if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then diff --git a/source/lambdas/pipeline_orchestration/index.py b/source/lambdas/pipeline_orchestration/index.py index cdd4d62..3a5e5e1 100644 --- a/source/lambdas/pipeline_orchestration/index.py +++ b/source/lambdas/pipeline_orchestration/index.py @@ -93,7 +93,7 @@ def provision_pipeline( # if the pipeline to provision is byom_image_builder if pipeline_type == "byom_image_builder": image_builder_params = get_image_builder_params(validated_event) - # format the params (the format is the same for multi-accouunt parameters) + # format the params (the format is the same for multi-account parameters) formatted_image_builder_params = format_template_parameters(image_builder_params, "True") # create the codepipeline stack_response = create_codepipeline_stack( @@ -140,7 +140,7 @@ def provision_pipeline( "isBase64Encoded": False, "body": json.dumps( { - "message": "success: stack creation started", + "message": stack_response["message"], "pipeline_id": stack_response["StackId"], } ), @@ -154,6 +154,7 @@ def update_stack( pipeline_template_url: str, template_parameters: List[Dict[str, str]], client: BaseClient, + stack_id: str, ) -> Dict[str, str]: try: update_response = client.update_stack( @@ -169,13 +170,14 @@ def update_stack( logger.info(update_response) - return {"StackId": f"Pipeline {codepipeline_stack_name} is being updated."} + return {"StackId": stack_id, "message": f"Pipeline {codepipeline_stack_name} is being updated."} except Exception as e: logger.info(f"Error during stack update {codepipeline_stack_name}: {str(e)}") if "No updates are to be performed" in str(e): return { - "StackId": f"Pipeline {codepipeline_stack_name} is already provisioned. No updates are to be performed." + "StackId": stack_id, + "message": f"Pipeline {codepipeline_stack_name} is already provisioned. No updates are to be performed.", } else: raise e @@ -201,18 +203,23 @@ def create_codepipeline_stack( ) logger.info(stack_response) - return stack_response + return {"StackId": stack_response["StackId"], "message": "success: stack creation started"} except Exception as e: - logger.error(f"Error in create_update_cf_stackset lambda functions: {str(e)}") + logger.error(f"Error in create_codepipeline_stack: {str(e)}") if "already exists" in str(e): logger.info(f"AWS Codepipeline {codepipeline_stack_name} already exists. Skipping codepipeline create") + # get the stack id using stack-name + stack_id = client.describe_stacks(StackName=codepipeline_stack_name)["Stacks"][0]["StackId"] # if the pipeline to update is BYOMPipelineImageBuilder if codepipeline_stack_name.endswith("byompipelineimagebuilder"): - return update_stack(codepipeline_stack_name, pipeline_template_url, template_parameters, client) + return update_stack( + codepipeline_stack_name, pipeline_template_url, template_parameters, client, stack_id + ) return { - "StackId": f"Pipeline {codepipeline_stack_name} is already provisioned. Updating template parameters." + "StackId": stack_id, + "message": f"Pipeline {codepipeline_stack_name} is already provisioned. Updating template parameters.", } else: raise e diff --git a/source/lambdas/pipeline_orchestration/shared/wrappers.py b/source/lambdas/pipeline_orchestration/shared/wrappers.py index 2540a27..69dd94f 100644 --- a/source/lambdas/pipeline_orchestration/shared/wrappers.py +++ b/source/lambdas/pipeline_orchestration/shared/wrappers.py @@ -12,42 +12,52 @@ # ##################################################################################################################### import json import sys +import os import traceback from functools import wraps -import boto3 +import botocore from shared.logger import get_logger -from shared.helper import get_client logger = get_logger(__name__) +endable_detailed_error_message = os.getenv("ALLOW_DETAILED_ERROR_MESSAGE", "Yes") class BadRequest(Exception): pass +def handle_exception(error_description, error_object, status_code): + # log the error + logger.error(f"{error_description}. Error: {str(error_object)}") + exc_type, exc_value, exc_tb = sys.exc_info() + logger.error(traceback.format_exception(exc_type, exc_value, exc_tb)) + # update the response body + body = {"message": error_description} + if endable_detailed_error_message == "Yes": + body.update({"detailedMessage": str(error_object)}) + + return { + "statusCode": status_code, + "isBase64Encoded": False, + "body": json.dumps(body), + "headers": {"Content-Type": "plain/text"}, + } + + def api_exception_handler(f): @wraps(f) def wrapper(event, context): try: return f(event, context) - except BadRequest as e: - logger.error(f"A BadRequest exception occurred, exception message: {str(e)}") - exc_type, exc_value, exc_tb = sys.exc_info() - logger.error(traceback.format_exception(exc_type, exc_value, exc_tb)) - return { - "statusCode": 400, - "isBase64Encoded": False, - "body": json.dumps({"message": str(e)}), - "headers": {"Content-Type": "plain/text"}, - } - except: - exc_type, exc_value, exc_tb = sys.exc_info() - logger.error(traceback.format_exception(exc_type, exc_value, exc_tb)) - return { - "statusCode": 500, - "isBase64Encoded": False, - "body": json.dumps({"message": "Internal server error. See logs for more information."}), - "headers": {"Content-Type": "plain/text"}, - } + + except BadRequest as bad_request_error: + return handle_exception("A BadRequest exception occurred", bad_request_error, 400) + + except botocore.exceptions.ClientError as client_error: + status_code = client_error.response["ResponseMetadata"]["HTTPStatusCode"] + return handle_exception("A boto3 ClientError occurred", client_error, status_code) + + except Exception as e: + return handle_exception("An Unexpected Server side exception occurred", e, 500) return wrapper diff --git a/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py b/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py index d70fb9e..87689c0 100644 --- a/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py +++ b/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py @@ -272,6 +272,11 @@ def stack_name(): return "teststack-testmodel-byompipelineimagebuilder" +@pytest.fixture +def stack_id(): + return "fake-stack-id" + + @pytest.fixture def expected_multi_account_params_format(): return [ @@ -690,5 +695,8 @@ def cf_client_params(api_byom_event, template_parameters_realtime_builtin): @pytest.fixture -def expected_update_response(stack_name): - return {"StackId": f"Pipeline {stack_name} is already provisioned. No updates are to be performed."} \ No newline at end of file +def expected_update_response(stack_name, stack_id): + return { + "StackId": stack_id, + "message": f"Pipeline {stack_name} is already provisioned. No updates are to be performed.", + } diff --git a/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py b/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py index 843c4be..edc074d 100644 --- a/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py +++ b/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py @@ -19,6 +19,7 @@ from unittest.mock import patch from unittest import TestCase import botocore.session +from botocore.exceptions import ClientError from botocore.stub import Stubber from moto import mock_s3 from pipeline_orchestration.lambda_helpers import ( @@ -59,6 +60,7 @@ expected_realtime_specific_params, expected_batch_specific_params, stack_name, + stack_id, api_data_quality_event, api_model_quality_event, expected_update_response, @@ -91,6 +93,7 @@ def test_handler(): + # event["path"] == "/provisionpipeline" with patch("pipeline_orchestration.index.provision_pipeline") as mock_provision_pipeline: event = { "httpMethod": "POST", @@ -108,13 +111,14 @@ def test_handler(): event = {"should_return": "bad_request"} response = handler(event, {}) + assert response == { "statusCode": 400, "isBase64Encoded": False, "body": json.dumps( { - "message": "Bad request format. Expected httpMethod or pipeline_type, received none. " - + "Check documentation for API & config formats." + "message": "A BadRequest exception occurred", + "detailedMessage": "Bad request format. Expected httpMethod or pipeline_type, received none. Check documentation for API & config formats.", } ), "headers": {"Content-Type": content_type}, @@ -130,11 +134,15 @@ def test_handler(): "statusCode": 400, "isBase64Encoded": False, "body": json.dumps( - {"message": "Unacceptable event path. Path must be /provisionpipeline or /pipelinestatus"} + { + "message": "A BadRequest exception occurred", + "detailedMessage": "Unacceptable event path. Path must be /provisionpipeline or /pipelinestatus", + } ), "headers": {"Content-Type": content_type}, } + # test event["path"] == "/pipelinestatus" with patch("pipeline_orchestration.index.pipeline_status") as mock_pipeline_status: event = { "httpMethod": "POST", @@ -144,14 +152,41 @@ def test_handler(): handler(event, {}) mock_pipeline_status.assert_called_with(json.loads(event["body"])) - # test the returned response "statusCode": 500 - mock_pipeline_status.side_effect = Exception() + # test for client error exception + mock_pipeline_status.side_effect = ClientError( + { + "Error": {"Code": "500", "Message": "Some error message"}, + "ResponseMetadata": {"HTTPStatusCode": 400}, + }, + "test", + ) + response = handler(event, {}) + assert response == { + "statusCode": 400, + "isBase64Encoded": False, + "body": json.dumps( + { + "message": "A boto3 ClientError occurred", + "detailedMessage": "An error occurred (500) when calling the test operation: Some error message", + } + ), + "headers": {"Content-Type": content_type}, + } + + # test for other exceptions + message = "An Unexpected Server side exception occurred" + mock_pipeline_status.side_effect = Exception(message) response = handler(event, {}) assert response == { "statusCode": 500, "isBase64Encoded": False, - "body": json.dumps({"message": "Internal server error. See logs for more information."}), - "headers": {"Content-Type": "plain/text"}, + "body": json.dumps( + { + "message": message, + "detailedMessage": message, + } + ), + "headers": {"Content-Type": content_type}, } @@ -215,12 +250,12 @@ def test_download_file_from_s3(): download_file_from_s3("assetsbucket", os.environ["TESTFILE"], testfile.name, s3_client) -def test_create_codepipeline_stack(cf_client_params, stack_name, expected_update_response): +def test_create_codepipeline_stack(cf_client_params, stack_name, stack_id, expected_update_response): cf_client = botocore.session.get_session().create_client("cloudformation") not_image_stack = "teststack-testmodel-BYOMPipelineReatimeBuiltIn" stubber = Stubber(cf_client) expected_params = cf_client_params - cfn_response = {"StackId": "1234"} + cfn_response = {"StackId": stack_id} stubber.add_response("create_stack", cfn_response, expected_params) with stubber: @@ -243,7 +278,22 @@ def test_create_codepipeline_stack(cf_client_params, stack_name, expected_update cf_client, ) stubber.add_client_error("create_stack", service_message="already exists") - expected_response = {"StackId": f"Pipeline {not_image_stack} is already provisioned. Updating template parameters."} + expected_response = { + "StackId": stack_id, + "message": f"Pipeline {not_image_stack} is already provisioned. Updating template parameters.", + } + describe_expected_params = {"StackName": not_image_stack} + describe_cfn_response = { + "Stacks": [ + { + "StackId": stack_id, + "StackName": not_image_stack, + "CreationTime": "2021-11-03T00:23:37.630000+00:00", + "StackStatus": "CREATE_COMPLETE", + } + ] + } + stubber.add_response("describe_stacks", describe_cfn_response, describe_expected_params) with stubber: response = create_codepipeline_stack( not_image_stack, @@ -255,50 +305,43 @@ def test_create_codepipeline_stack(cf_client_params, stack_name, expected_update assert response == expected_response # Test if the stack is image builder + describe_expected_params["StackName"] = stack_name + describe_cfn_response["Stacks"][0]["StackName"] = stack_name stubber.add_client_error("create_stack", service_message="already exists") + stubber.add_response("describe_stacks", describe_cfn_response, describe_expected_params) stubber.add_client_error("update_stack", service_message="No updates are to be performed") expected_response = expected_update_response with stubber: response = create_codepipeline_stack( - stack_name, - expected_params["TemplateURL"], - expected_params["Parameters"], - cf_client, + stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client ) assert response == expected_response -def test_update_stack(cf_client_params, stack_name, expected_update_response): +def test_update_stack(cf_client_params, stack_name, stack_id, expected_update_response): cf_client = botocore.session.get_session().create_client("cloudformation") - expected_params = cf_client_params stubber = Stubber(cf_client) expected_params["StackName"] = stack_name expected_params["Tags"] = [{"Key": "stack_name", "Value": stack_name}] del expected_params["OnFailure"] - cfn_response = {"StackId": f"Pipeline {stack_name} is being updated."} + cfn_response = {"StackId": stack_id} stubber.add_response("update_stack", cfn_response, expected_params) with stubber: response = update_stack( - stack_name, - expected_params["TemplateURL"], - expected_params["Parameters"], - cf_client, + stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client, stack_id ) - assert response == cfn_response + assert response == {**cfn_response, "message": f"Pipeline {stack_name} is being updated."} # Test for no update error stubber.add_client_error("update_stack", service_message="No updates are to be performed") expected_response = expected_update_response with stubber: response = update_stack( - stack_name, - expected_params["TemplateURL"], - expected_params["Parameters"], - cf_client, + stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client, stack_id ) assert response == expected_response @@ -306,12 +349,7 @@ def test_update_stack(cf_client_params, stack_name, expected_update_response): stubber.add_client_error("update_stack", service_message="Some Exception") with stubber: with pytest.raises(Exception): - update_stack( - stack_name, - expected_params["TemplateURL"], - expected_params["Parameters"], - cf_client, - ) + update_stack(stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client, stack_id) def test_pipeline_status(): @@ -610,7 +648,9 @@ def test_get_model_monitor_params( expected_data_quality_monitor_params, expected_model_quality_monitor_params, ): - # provide an actual Model Monitor image URI (us-east-1) as the return value + # The 156813124566 is one of the actual account ids for a public Model Monitor Image provided + # by the SageMaker service. The reason is I need to provide a valid image URI because the SDK + # has validation for the inputs mocked_image_retrieve.return_value = "156813124566.dkr.ecr.us-east-1.amazonaws.com/sagemaker-model-monitor-analyzer" # data quality monitor TestCase().assertEqual( diff --git a/source/lib/aws_mlops_stack.py b/source/lib/aws_mlops_stack.py index 239fa80..405f349 100644 --- a/source/lib/aws_mlops_stack.py +++ b/source/lib/aws_mlops_stack.py @@ -67,6 +67,8 @@ def __init__(self, scope: core.Construct, id: str, *, multi_account=False, **kwa use_model_registry = pf.create_use_model_registry_parameter(self) # Does the user want the solution to create model registry create_model_registry = pf.create_model_registry_parameter(self) + # Enable detailed error message in the API response + allow_detailed_error_message = pf.create_detailed_error_message_parameter(self) # Conditions git_address_provided = cf.create_git_address_provided_condition(self, git_address) @@ -284,6 +286,9 @@ def __init__(self, scope: core.Construct, id: str, *, multi_account=False, **kwa provisioner_apigw_lambda.lambda_function.add_environment( key="USE_MODEL_REGISTRY", value=use_model_registry.value_as_string ) + provisioner_apigw_lambda.lambda_function.add_environment( + key="ALLOW_DETAILED_ERROR_MESSAGE", value=allow_detailed_error_message.value_as_string + ) provisioner_apigw_lambda.lambda_function.add_environment(key="ECR_REPO_NAME", value=ecr_repo_name) @@ -402,6 +407,7 @@ def __init__(self, scope: core.Construct, id: str, *, multi_account=False, **kwa existing_ecr_repo.logical_id, use_model_registry.logical_id, create_model_registry.logical_id, + allow_detailed_error_message.logical_id, ] paramaters_labels = { @@ -413,6 +419,9 @@ def __init__(self, scope: core.Construct, id: str, *, multi_account=False, **kwa f"{create_model_registry.logical_id}": { "default": "Do you want the solution to create a SageMaker's model package group?" }, + f"{allow_detailed_error_message.logical_id}": { + "default": "Do you want to allow detailed error messages in the APIs response?" + }, } # configure mutli-account parameters and permissions diff --git a/source/lib/blueprints/byom/pipeline_definitions/iam_policies.py b/source/lib/blueprints/byom/pipeline_definitions/iam_policies.py index b281a3a..60266bf 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/iam_policies.py +++ b/source/lib/blueprints/byom/pipeline_definitions/iam_policies.py @@ -385,6 +385,7 @@ def create_orchestrator_policy( "cloudformation:CreateStack", "cloudformation:DeleteStack", "cloudformation:UpdateStack", + "cloudformation:DescribeStacks", "cloudformation:ListStackResources", ], resources=[ diff --git a/source/lib/blueprints/byom/pipeline_definitions/templates_parameters.py b/source/lib/blueprints/byom/pipeline_definitions/templates_parameters.py index 86124a8..a160129 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/templates_parameters.py +++ b/source/lib/blueprints/byom/pipeline_definitions/templates_parameters.py @@ -22,7 +22,7 @@ def create_notification_email_parameter(scope: core.Construct) -> core.CfnParame type="String", description="email for pipeline outcome notifications", allowed_pattern="^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", - constraint_description="Please enter an email address with correct format (example@exmaple.com)", + constraint_description="Please enter an email address with correct format (example@example.com)", min_length=5, max_length=320, ) @@ -424,6 +424,17 @@ def create_delegated_admin_parameter(scope: core.Construct) -> core.CfnParameter description="Is a delegated administrator account used to deploy accross account", ) + @staticmethod + def create_detailed_error_message_parameter(scope: core.Construct) -> core.CfnParameter: + return core.CfnParameter( + scope, + "AllowDetailedErrorMessage", + type="String", + allowed_values=["Yes", "No"], + default="Yes", + description="Allow including a detailed message of any server-side errors in the API call's response", + ) + @staticmethod def create_use_model_registry_parameter(scope: core.Construct) -> core.CfnParameter: return core.CfnParameter( diff --git a/source/requirements.txt b/source/requirements.txt index 7fea932..5f72451 100644 --- a/source/requirements.txt +++ b/source/requirements.txt @@ -1,34 +1,34 @@ -aws-cdk.assets==1.117.0 -aws-cdk.aws-apigateway==1.117.0 -aws-cdk.aws-cloudformation==1.117.0 -aws-cdk.aws-cloudwatch==1.117.0 -aws-cdk.aws-codebuild==1.117.0 -aws-cdk.aws-codecommit==1.117.0 -aws-cdk.aws-codedeploy==1.117.0 -aws-cdk.aws-codepipeline==1.117.0 -aws-cdk.aws-codepipeline-actions==1.117.0 -aws-cdk.core==1.117.0 -aws-cdk.aws-ecr==1.117.0 -aws-cdk.aws-ecr-assets==1.117.0 -aws-cdk.aws-events==1.117.0 -aws-cdk.aws-events-targets==1.117.0 -aws-cdk.aws-iam==1.117.0 -aws-cdk.aws-kms==1.117.0 -aws-cdk.aws-lambda==1.117.0 -aws-cdk.aws-lambda-event-sources==1.117.0 -aws-cdk.aws-logs==1.117.0 -aws-cdk.aws-s3==1.117.0 -aws-cdk.aws-s3-assets==1.117.0 -aws-cdk.aws-s3-deployment==1.117.0 -aws-cdk.aws-s3-notifications==1.117.0 -aws-cdk.aws-sagemaker==1.117.0 -aws-cdk.aws-sns==1.117.0 -aws-cdk.aws-sns-subscriptions==1.117.0 -aws-cdk.core==1.117.0 -aws-cdk.custom-resources==1.117.0 -aws-cdk.region-info==1.117.0 -aws-solutions-constructs.aws-apigateway-lambda==1.117.0 -aws-solutions-constructs.aws-lambda-sagemakerendpoint==1.117.0 -aws-solutions-constructs.core==1.117.0 -aws-cdk.cloudformation-include==1.117.0 -aws-cdk.aws-cloudformation==1.117.0 +aws-cdk.assets==1.126.0 +aws-cdk.aws-apigateway==1.126.0 +aws-cdk.aws-cloudformation==1.126.0 +aws-cdk.aws-cloudwatch==1.126.0 +aws-cdk.aws-codebuild==1.126.0 +aws-cdk.aws-codecommit==1.126.0 +aws-cdk.aws-codedeploy==1.126.0 +aws-cdk.aws-codepipeline==1.126.0 +aws-cdk.aws-codepipeline-actions==1.126.0 +aws-cdk.core==1.126.0 +aws-cdk.aws-ecr==1.126.0 +aws-cdk.aws-ecr-assets==1.126.0 +aws-cdk.aws-events==1.126.0 +aws-cdk.aws-events-targets==1.126.0 +aws-cdk.aws-iam==1.126.0 +aws-cdk.aws-kms==1.126.0 +aws-cdk.aws-lambda==1.126.0 +aws-cdk.aws-lambda-event-sources==1.126.0 +aws-cdk.aws-logs==1.126.0 +aws-cdk.aws-s3==1.126.0 +aws-cdk.aws-s3-assets==1.126.0 +aws-cdk.aws-s3-deployment==1.126.0 +aws-cdk.aws-s3-notifications==1.126.0 +aws-cdk.aws-sagemaker==1.126.0 +aws-cdk.aws-sns==1.126.0 +aws-cdk.aws-sns-subscriptions==1.126.0 +aws-cdk.core==1.126.0 +aws-cdk.custom-resources==1.126.0 +aws-cdk.region-info==1.126.0 +aws-solutions-constructs.aws-apigateway-lambda==1.126.0 +aws-solutions-constructs.aws-lambda-sagemakerendpoint==1.126.0 +aws-solutions-constructs.core==1.126.0 +aws-cdk.cloudformation-include==1.126.0 +aws-cdk.aws-cloudformation==1.126.0 From a96a8688790d2a6644656ed08b9a0d145bf17c3c Mon Sep 17 00:00:00 2001 From: Alireza Assadzadeh Date: Mon, 20 Dec 2021 11:39:56 -0500 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b871dcd..eddc302 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.4.1] - +## [1.4.1] - 2021-12-20 ### Added