Repository: getmoto/moto
Pull request #7039 (2023-11-17 16:25:34)

Fix CloudFormation AWS::Logs::LogGroup tagging
CloudFormation AWS::Logs::LogGroup expects tags as an array of Tags:

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-resource-tags.html

Whereas boto3's Logs `create_log_group()` expects tags to be a dict:

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/logs/client/create_log_group.html

So we need to transform how we provide tags to avoid getting:

```
E       AttributeError: 'list' object has no attribute 'items'
```


Patch:
diff --git a/docs/docs/proxy_mode.rst b/docs/docs/proxy_mode.rst
index c2dd41e4c..f577d6426 100644
--- a/docs/docs/proxy_mode.rst
+++ b/docs/docs/proxy_mode.rst
@@ -12,6 +12,7 @@ Proxy Mode
 
 Moto can be run as a proxy, intercepting all requests to AWS and mocking them instead.  :raw-html:`<br />`
 Some of the benefits:
+
  - Easy to configure for all SDK's
  - Can be reached by Lambda containers, allowing you to mock service-calls inside a Lambda-function
 
@@ -69,7 +70,7 @@ The `AWS_CA_BUNDLE` needs to point to the location of the CA certificate that co
 You can run `moto_proxy --help` to get the exact location of this certificate, depending on where Moto is installed.
 
 Environment Variables Configuration:
-------------------------------
+-------------------------------------
 
 .. code-block:: bash
 
diff --git a/docs/docs/releases.rst b/docs/docs/releases.rst
new file mode 100644
index 000000000..c0ff5e9a7
--- /dev/null
+++ b/docs/docs/releases.rst
@@ -0,0 +1,53 @@
+.. _releases:
+
+.. role:: bash(code)
+   :language: bash
+
+.. role:: raw-html(raw)
+    :format: html
+
+================================
+Releases and Upgrade Paths
+================================
+
+
+Release Schedule
+------------------
+
+There is no fixed release schedule, although we try to release a new version every 1 or 2 weeks.
+
+Specific release intervals will be influenced by 1) high-priority bugs, 2) number of new features and 3) maintainer availability.
+
+
+Development Releases
+----------------------
+
+Every commit should result in a development release, marked as `Pre-releases in PyPi <https://pypi.org/project/moto/#history>`_ and with the `latest`-tag `in DockerHub <https://hub.docker.com/r/motoserver/moto/tags>`_.
+
+
+Versioning scheme
+----------------------
+
+Moto follows a `semver` scheme: `major.minor.patch`.
+ - A major releases indicates a breaking change
+ - A minor release indicates a big change, but nothing breaking
+ - A patch release will contain new features and bug fixes
+
+
+Breaking Changes
+-----------------
+
+A full list of all changes in a specific release can be found on Github: https://github.com/getmoto/moto/blob/master/CHANGELOG.md
+
+A overview of the breaking changes between major versions:
+
+For Moto 2.x:
+ - A change in the installation method. Use `pip install moto[service]` to only install the required dependencies for that service, or `pip install moto[all]` to install all (1.x behaviour)
+
+For Moto 3.x:
+ - Removed compatibility with `boto`. Specifically: all `service_deprecated`-decorators were removed.
+ - The class-decorator now resets the state before every test-method (before, the state was global - shared between class-methods).
+ - ECS ARN's now use the new (long) format by default
+
+For Moto 4.x:
+ - Removed decorators `mock_dynamodb2` and `mock_rds2` (they were functionally equivalent with `mock_dynamodb` and `mock_rds` since 3.x)
\ No newline at end of file
diff --git a/docs/docs/server_mode.rst b/docs/docs/server_mode.rst
index 7493c730f..d3faf9647 100644
--- a/docs/docs/server_mode.rst
+++ b/docs/docs/server_mode.rst
@@ -84,6 +84,7 @@ See the following example:
             in_mem_client = boto3.client("s3")
             buckets = in_mem_client.list_buckets()["Buckets"]
             self.assertEqual([b["Name"] for b in buckets], ["test"])
+
 This example shows it is possible to create state using the TreadedMotoServer, and access that state using the usual decorators.  :raw-html:`<br />`
 Note that the decorators will destroy any resources on start, so make sure to not accidentally destroy any resources created by the ThreadedMotoServer that should be kept.
 
@@ -119,6 +120,10 @@ Example Usage
 
 To use Moto in your tests, pass the `endpoint_url`-parameter to the SDK of your choice.
 
+For some SDK's, this can be configured using the environment variable `AWS_ENDPOINT_URL`. See the official AWS documentation: https://docs.aws.amazon.com/sdkref/latest/guide/feature-ss-endpoints.html
+
+If your SDK does not support this, here are some code samples to set this parameter the old-fashioned way.
+
 In Python:
 
 .. code-block:: python
diff --git a/docs/index.rst b/docs/index.rst
index 9c7e9496f..63e93ec64 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -37,6 +37,7 @@ Additional Resources
    docs/iam
    docs/aws_config
    docs/multi_account
+   docs/releases
 
 .. toctree::
   :hidden:
diff --git a/moto/awslambda/responses.py b/moto/awslambda/responses.py
index b00dc7138..c06eb7b69 100644
--- a/moto/awslambda/responses.py
+++ b/moto/awslambda/responses.py
@@ -269,7 +269,10 @@ class LambdaResponse(BaseResponse):
             elif request.headers.get("X-Amz-Invocation-Type") == "DryRun":
                 status_code = 204
             else:
-                if request.headers.get("X-Amz-Log-Type") != "Tail":
+                if (
+                    request.headers.get("X-Amz-Log-Type") != "Tail"
+                    and "x-amz-log-result" in response_headers
+                ):
                     del response_headers["x-amz-log-result"]
                 status_code = 200
             return status_code, response_headers, payload
diff --git a/moto/awslambda_simple/models.py b/moto/awslambda_simple/models.py
index b1446b13e..68b7e32aa 100644
--- a/moto/awslambda_simple/models.py
+++ b/moto/awslambda_simple/models.py
@@ -51,6 +51,8 @@ class LambdaSimpleBackend(BaseBackend):
         response_headers: Any,
     ) -> Optional[Union[str, bytes]]:
 
+        if body:
+            return str.encode(body)
         return b"Simple Lambda happy path OK"
 
 
diff --git a/moto/logs/models.py b/moto/logs/models.py
index 8e960651b..878040185 100644
--- a/moto/logs/models.py
+++ b/moto/logs/models.py
@@ -410,7 +410,9 @@ class LogGroup(CloudFormationModel):
         **kwargs: Any,
     ) -> "LogGroup":
         properties = cloudformation_json["Properties"]
-        tags = properties.get("Tags", {})
+        tags = properties.get("Tags", [])
+        tags = dict([tag.values() for tag in tags])
+
         return logs_backends[account_id][region_name].create_log_group(
             resource_name, tags, **properties
         )


Test patch:
diff --git a/tests/test_awslambda_simple/test_lambda_simple.py b/tests/test_awslambda_simple/test_lambda_simple.py
index 3d0dccfd2..e316b7890 100644
--- a/tests/test_awslambda_simple/test_lambda_simple.py
+++ b/tests/test_awslambda_simple/test_lambda_simple.py
@@ -1,3 +1,4 @@
+import json
 from unittest import SkipTest
 import boto3
 
@@ -15,6 +16,36 @@ if settings.TEST_SERVER_MODE:
 @mock_iam
 @mock_lambda_simple
 def test_run_function():
+    # Setup
+    client = setup_lambda()
+
+    # Execute
+    result = client.invoke(
+        FunctionName=FUNCTION_NAME,
+        LogType="Tail",
+    )
+
+    # Verify
+    assert result["StatusCode"] == 200
+    assert result["Payload"].read().decode("utf-8") == "Simple Lambda happy path OK"
+
+
+@mock_iam
+@mock_lambda_simple
+def test_run_function_no_log():
+    # Setup
+    client = setup_lambda()
+    payload = {"results": "results"}
+
+    # Execute
+    result = client.invoke(FunctionName=FUNCTION_NAME, Payload=json.dumps(payload))
+
+    # Verify
+    assert result["StatusCode"] == 200
+    assert json.loads(result["Payload"].read().decode("utf-8")) == payload
+
+
+def setup_lambda():
     client = boto3.client("lambda", LAMBDA_REGION)
     zip_content = get_test_zip_file1()
     function_name = FUNCTION_NAME
@@ -30,10 +61,4 @@ def test_run_function():
         MemorySize=128,
         Publish=True,
     )
-
-    result = client.invoke(
-        FunctionName=FUNCTION_NAME,
-        LogType="Tail",
-    )
-    assert result["StatusCode"] == 200
-    assert result["Payload"].read().decode("utf-8") == "Simple Lambda happy path OK"
+    return client
diff --git a/tests/test_eks/test_server.py b/tests/test_eks/test_server.py
index 6abb22cc4..348620698 100644
--- a/tests/test_eks/test_server.py
+++ b/tests/test_eks/test_server.py
@@ -1,9 +1,10 @@
 import json
 import pytest
+import unittest
 
 from copy import deepcopy
 import moto.server as server
-from moto import mock_eks
+from moto import mock_eks, settings
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 from moto.eks.exceptions import ResourceInUseException, ResourceNotFoundException
 from moto.eks.models import (
@@ -79,6 +80,8 @@ class TestNodegroup:
 
 @pytest.fixture(autouse=True, name="test_client")
 def fixture_test_client():
+    if settings.TEST_SERVER_MODE:
+        raise unittest.SkipTest("No point in testing this in ServerMode")
     backend = server.create_backend_app(service=SERVICE)
     yield backend.test_client()
 
diff --git a/tests/test_logs/test_logs_cloudformation.py b/tests/test_logs/test_logs_cloudformation.py
new file mode 100644
index 000000000..016581aff
--- /dev/null
+++ b/tests/test_logs/test_logs_cloudformation.py
@@ -0,0 +1,31 @@
+import json
+
+import boto3
+
+from moto import mock_cloudformation, mock_logs
+
+
+@mock_logs
+@mock_cloudformation
+def test_tagging():
+    logs_client = boto3.client("logs", region_name="us-east-1")
+    cf_client = boto3.client("cloudformation", region_name="us-east-1")
+
+    template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Resources": {
+            "testGroup": {
+                "Type": "AWS::Logs::LogGroup",
+                "Properties": {"Tags": [{"Key": "foo", "Value": "bar"}]},
+            }
+        },
+    }
+    template_json = json.dumps(template)
+    cf_client.create_stack(
+        StackName="test_stack",
+        TemplateBody=template_json,
+    )
+
+    arn = logs_client.describe_log_groups()["logGroups"][0]["arn"]
+    tags = logs_client.list_tags_for_resource(resourceArn=arn)["tags"]
+    assert tags == {"foo": "bar"}
diff --git a/tests/test_redshift/test_server.py b/tests/test_redshift/test_server.py
index f55ea3cd8..4b1f86c55 100644
--- a/tests/test_redshift/test_server.py
+++ b/tests/test_redshift/test_server.py
@@ -1,12 +1,18 @@
 """Test the different server responses."""
 import json
 import re
-
 import pytest
 import xmltodict
+import unittest
 
 import moto.server as server
-from moto import mock_redshift
+from moto import mock_redshift, settings
+
+
+@pytest.fixture(scope="function", autouse=True)
+def skip_in_server_mode():
+    if settings.TEST_SERVER_MODE:
+        raise unittest.SkipTest("No point in testing this in ServerMode")
 
 
 @mock_redshift
diff --git a/tests/test_redshiftdata/test_server.py b/tests/test_redshiftdata/test_server.py
index 92d566114..b81f72010 100644
--- a/tests/test_redshiftdata/test_server.py
+++ b/tests/test_redshiftdata/test_server.py
@@ -1,9 +1,10 @@
 """Test different server responses."""
 import json
-from uuid import UUID
-
 import pytest
+import unittest
+from uuid import UUID
 
+from moto import settings
 import moto.server as server
 from tests.test_redshiftdata.test_redshiftdata_constants import (
     DEFAULT_ENCODING,
@@ -13,6 +14,12 @@ from tests.test_redshiftdata.test_redshiftdata_constants import (
 CLIENT_ENDPOINT = "/"
 
 
+@pytest.fixture(scope="function", autouse=True)
+def skip_in_server_mode():
+    if settings.TEST_SERVER_MODE:
+        raise unittest.SkipTest("No point in testing this in ServerMode")
+
+
 def headers(action):
     return {
         "X-Amz-Target": f"RedshiftData.{action}",
diff --git a/tests/test_secretsmanager/test_server.py b/tests/test_secretsmanager/test_server.py
index 26a809464..9911eb9cd 100644
--- a/tests/test_secretsmanager/test_server.py
+++ b/tests/test_secretsmanager/test_server.py
@@ -3,6 +3,7 @@ import json
 
 import boto3
 import pytest
+import unittest
 
 import moto.server as server
 from moto import mock_secretsmanager, mock_lambda, mock_iam, mock_logs, settings
@@ -12,6 +13,12 @@ from tests.test_awslambda.test_lambda import get_test_zip_file1
 DEFAULT_SECRET_NAME = "test-secret"
 
 
+@pytest.fixture(scope="function", autouse=True)
+def skip_in_server_mode():
+    if settings.TEST_SERVER_MODE:
+        raise unittest.SkipTest("No point in testing this in ServerMode")
+
+
 @mock_secretsmanager
 def test_get_secret_value():
 
@@ -329,11 +336,7 @@ def test_rotate_secret_that_is_still_rotating():
 
     create_secret = test_client.post(
         "/",
-        data={
-            "Name": DEFAULT_SECRET_NAME,
-            "SecretString": "foosecret",
-            # "VersionStages": ["AWSPENDING"],
-        },
+        data={"Name": DEFAULT_SECRET_NAME, "SecretString": "foosecret"},
         headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
     )
     create_secret = json.loads(create_secret.data.decode("utf-8"))
@@ -450,78 +453,77 @@ def test_rotate_secret_rotation_lambda_arn_too_long():
     assert json_data["__type"] == "InvalidParameterException"
 
 
-if not settings.TEST_SERVER_MODE:
-
-    @mock_iam
-    @mock_lambda
-    @mock_logs
-    @mock_secretsmanager
-    @requires_docker
-    def test_rotate_secret_lambda_invocations():
-        conn = boto3.client("iam", region_name="us-east-1")
-        logs_conn = boto3.client("logs", region_name="us-east-1")
-        role = conn.create_role(
-            RoleName="role", AssumeRolePolicyDocument="some policy", Path="/my-path/"
-        )
-
-        conn = boto3.client("lambda", region_name="us-east-1")
-        func = conn.create_function(
-            FunctionName="testFunction",
-            Code={"ZipFile": get_test_zip_file1()},
-            Handler="lambda_function.lambda_handler",
-            Runtime="python3.11",
-            Role=role["Role"]["Arn"],
-        )
-
-        secretsmanager_backend = server.create_backend_app("secretsmanager")
-        secretsmanager_client = secretsmanager_backend.test_client()
-
-        secretsmanager_client.post(
-            "/",
-            data={"Name": DEFAULT_SECRET_NAME, "SecretString": "foosecret"},
-            headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
-        )
-
-        with pytest.raises(logs_conn.exceptions.ResourceNotFoundException):
-            # The log group doesn't exist yet
-            logs_conn.describe_log_streams(logGroupName="/aws/lambda/testFunction")
-
-        secretsmanager_client.post(
-            "/",
-            data={
-                "SecretId": DEFAULT_SECRET_NAME,
-                "RotationLambdaARN": func["FunctionArn"],
-            },
-            headers={"X-Amz-Target": "secretsmanager.RotateSecret"},
-        )
-
-        # The log group now exists and has been logged to 4 times (for each invocation)
-        logs = logs_conn.describe_log_streams(logGroupName="/aws/lambda/testFunction")
-        assert len(logs["logStreams"]) == 4
-
-    @mock_iam
-    @mock_lambda
-    @mock_logs
-    @mock_secretsmanager
-    def test_rotate_secret_with_incorrect_lambda_arn():
-        secretsmanager_backend = server.create_backend_app("secretsmanager")
-        secretsmanager_client = secretsmanager_backend.test_client()
-
-        secretsmanager_client.post(
-            "/",
-            data={"Name": DEFAULT_SECRET_NAME, "SecretString": "foosecret"},
-            headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
-        )
-
-        resp = secretsmanager_client.post(
-            "/",
-            data={"SecretId": DEFAULT_SECRET_NAME, "RotationLambdaARN": "notarealarn"},
-            headers={"X-Amz-Target": "secretsmanager.RotateSecret"},
-        )
-        json_data = json.loads(resp.data.decode("utf-8"))
-        assert json_data["message"] == "Resource not found for ARN 'notarealarn'."
-        assert json_data["__type"] == "ResourceNotFoundException"
-        assert resp.status_code == 404
+@mock_iam
+@mock_lambda
+@mock_logs
+@mock_secretsmanager
+@requires_docker
+def test_rotate_secret_lambda_invocations():
+    conn = boto3.client("iam", region_name="us-east-1")
+    logs_conn = boto3.client("logs", region_name="us-east-1")
+    role = conn.create_role(
+        RoleName="role", AssumeRolePolicyDocument="some policy", Path="/my-path/"
+    )
+
+    conn = boto3.client("lambda", region_name="us-east-1")
+    func = conn.create_function(
+        FunctionName="testFunction",
+        Code={"ZipFile": get_test_zip_file1()},
+        Handler="lambda_function.lambda_handler",
+        Runtime="python3.11",
+        Role=role["Role"]["Arn"],
+    )
+
+    secretsmanager_backend = server.create_backend_app("secretsmanager")
+    secretsmanager_client = secretsmanager_backend.test_client()
+
+    secretsmanager_client.post(
+        "/",
+        data={"Name": DEFAULT_SECRET_NAME, "SecretString": "foosecret"},
+        headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
+    )
+
+    with pytest.raises(logs_conn.exceptions.ResourceNotFoundException):
+        # The log group doesn't exist yet
+        logs_conn.describe_log_streams(logGroupName="/aws/lambda/testFunction")
+
+    secretsmanager_client.post(
+        "/",
+        data={
+            "SecretId": DEFAULT_SECRET_NAME,
+            "RotationLambdaARN": func["FunctionArn"],
+        },
+        headers={"X-Amz-Target": "secretsmanager.RotateSecret"},
+    )
+
+    # The log group now exists and has been logged to 4 times (for each invocation)
+    logs = logs_conn.describe_log_streams(logGroupName="/aws/lambda/testFunction")
+    assert len(logs["logStreams"]) == 4
+
+
+@mock_iam
+@mock_lambda
+@mock_logs
+@mock_secretsmanager
+def test_rotate_secret_with_incorrect_lambda_arn():
+    secretsmanager_backend = server.create_backend_app("secretsmanager")
+    secretsmanager_client = secretsmanager_backend.test_client()
+
+    secretsmanager_client.post(
+        "/",
+        data={"Name": DEFAULT_SECRET_NAME, "SecretString": "foosecret"},
+        headers={"X-Amz-Target": "secretsmanager.CreateSecret"},
+    )
+
+    resp = secretsmanager_client.post(
+        "/",
+        data={"SecretId": DEFAULT_SECRET_NAME, "RotationLambdaARN": "notarealarn"},
+        headers={"X-Amz-Target": "secretsmanager.RotateSecret"},
+    )
+    json_data = json.loads(resp.data.decode("utf-8"))
+    assert json_data["message"] == "Resource not found for ARN 'notarealarn'."
+    assert json_data["__type"] == "ResourceNotFoundException"
+    assert resp.status_code == 404
 
 
 @mock_secretsmanager