Repository: getmoto/moto
Pull request #6429 (2023-06-20 19:03:38)

add support for maintenance window targets
This PR adds support for the next operations:

- [x] register_target_with_maintenance_window
- [x] describe_maintenance_window_targets
- [x] deregister_target_from_maintenance_window


Patch:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index a91e89a06..42bb497a3 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -6744,7 +6744,7 @@
 - [ ] delete_resource_policy
 - [ ] deregister_managed_instance
 - [ ] deregister_patch_baseline_for_patch_group
-- [ ] deregister_target_from_maintenance_window
+- [X] deregister_target_from_maintenance_window
 - [ ] deregister_task_from_maintenance_window
 - [ ] describe_activations
 - [ ] describe_association
@@ -6767,7 +6767,7 @@
 - [ ] describe_maintenance_window_execution_tasks
 - [ ] describe_maintenance_window_executions
 - [ ] describe_maintenance_window_schedule
-- [ ] describe_maintenance_window_targets
+- [X] describe_maintenance_window_targets
 - [ ] describe_maintenance_window_tasks
 - [X] describe_maintenance_windows
 - [ ] describe_maintenance_windows_for_target
@@ -6828,7 +6828,7 @@
 - [ ] put_resource_policy
 - [ ] register_default_patch_baseline
 - [ ] register_patch_baseline_for_patch_group
-- [ ] register_target_with_maintenance_window
+- [X] register_target_with_maintenance_window
 - [ ] register_task_with_maintenance_window
 - [X] remove_tags_from_resource
 - [ ] reset_service_setting
diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py
index 6eb8d515e..780ed933c 100644
--- a/moto/ec2/exceptions.py
+++ b/moto/ec2/exceptions.py
@@ -444,6 +444,14 @@ class InvalidParameterValueErrorTagSpotFleetRequest(EC2ClientError):
         )
 
 
+class InvalidParameterValueErrorReplaceRoute(EC2ClientError):
+    def __init__(self, cidr: str):
+        super().__init__(
+            "InvalidParameterValue",
+            f"There is no route defined for '{cidr}' in the route table. Use CreateRoute instead.",
+        )
+
+
 class EmptyTagSpecError(EC2ClientError):
     def __init__(self) -> None:
         super().__init__(
diff --git a/moto/ec2/models/route_tables.py b/moto/ec2/models/route_tables.py
index de6217d09..9582f1167 100644
--- a/moto/ec2/models/route_tables.py
+++ b/moto/ec2/models/route_tables.py
@@ -18,6 +18,7 @@ from ..exceptions import (
     InvalidRouteTableIdError,
     InvalidAssociationIdError,
     InvalidDestinationCIDRBlockParameterError,
+    InvalidParameterValueErrorReplaceRoute,
     RouteAlreadyExistsError,
     RouteNotSupportedError,
 )
@@ -447,11 +448,22 @@ class RouteBackend:
         interface_id: Optional[str] = None,
         vpc_peering_connection_id: Optional[str] = None,
     ) -> Route:
+        cidr = destination_cidr_block
+        if destination_ipv6_cidr_block:
+            cidr = destination_ipv6_cidr_block
+        if destination_prefix_list_id:
+            cidr = destination_prefix_list_id
         route_table = self.get_route_table(route_table_id)
         route_id = generate_route_id(
             route_table.id, destination_cidr_block, destination_ipv6_cidr_block
         )
-        route = route_table.routes[route_id]
+        try:
+            route = route_table.routes[route_id]
+        except KeyError:
+            # This should be 'raise InvalidRouteError(route_table_id, cidr)' in
+            # line with the delete_route() equivalent, but for some reason AWS
+            # returns InvalidParameterValue instead in this case.
+            raise InvalidParameterValueErrorReplaceRoute(cidr) from None
 
         route.gateway = None
         route.nat_gateway = None
diff --git a/moto/ssm/exceptions.py b/moto/ssm/exceptions.py
index 4c6b69ca6..29813c22d 100644
--- a/moto/ssm/exceptions.py
+++ b/moto/ssm/exceptions.py
@@ -139,3 +139,13 @@ class ParameterMaxVersionLimitExceeded(JsonRESTError):
 
     def __init__(self, message: str):
         super().__init__("ParameterMaxVersionLimitExceeded", message)
+
+
+class ParameterAlreadyExists(JsonRESTError):
+    code = 400
+
+    def __init__(self) -> None:
+        super().__init__(
+            "ParameterAlreadyExists",
+            "The parameter already exists. To overwrite this value, set the overwrite option in the request to true.",
+        )
diff --git a/moto/ssm/models.py b/moto/ssm/models.py
index 8b21fd4d7..36865ec1a 100644
--- a/moto/ssm/models.py
+++ b/moto/ssm/models.py
@@ -941,6 +941,48 @@ def _valid_parameter_data_type(data_type: str) -> bool:
     return data_type in ("text", "aws:ec2:image")
 
 
+class FakeMaintenanceWindowTarget:
+    def __init__(
+        self,
+        window_id: str,
+        resource_type: str,
+        targets: List[Dict[str, Any]],
+        owner_information: Optional[str],
+        name: Optional[str],
+        description: Optional[str],
+    ):
+        self.window_id = window_id
+        self.window_target_id = self.generate_id()
+        self.resource_type = resource_type
+        self.targets = targets
+        self.name = name
+        self.description = description
+        self.owner_information = owner_information
+
+    def to_json(self) -> Dict[str, Any]:
+        return {
+            "WindowId": self.window_id,
+            "WindowTargetId": self.window_target_id,
+            "ResourceType": self.resource_type,
+            "Targets": self.targets,
+            "OwnerInformation": "",
+            "Name": self.name,
+            "Description": self.description,
+        }
+
+    @staticmethod
+    def generate_id() -> str:
+        return str(random.uuid4())
+
+
+def _maintenance_window_target_filter_match(
+    filters: Optional[List[Dict[str, Any]]], target: FakeMaintenanceWindowTarget
+) -> bool:
+    if not filters and target:
+        return True
+    return False
+
+
 class FakeMaintenanceWindow:
     def __init__(
         self,
@@ -964,6 +1006,7 @@ class FakeMaintenanceWindow:
         self.schedule_offset = schedule_offset
         self.start_date = start_date
         self.end_date = end_date
+        self.targets: Dict[str, FakeMaintenanceWindowTarget] = {}
 
     def to_json(self) -> Dict[str, Any]:
         return {
@@ -1952,7 +1995,11 @@ class SimpleSystemManagerBackend(BaseBackend):
                 )
             raise ValidationException(invalid_prefix_error)
 
-        if not _valid_parameter_type(parameter_type):
+        if (
+            not _valid_parameter_type(parameter_type)
+            and not overwrite
+            and name not in self._parameters
+        ):
             raise ValidationException(
                 f"1 validation error detected: Value '{parameter_type}' at 'type' failed to satisfy constraint: Member must satisfy enum value set: [SecureString, StringList, String]",
             )
@@ -2250,5 +2297,53 @@ class SimpleSystemManagerBackend(BaseBackend):
         """
         del self.baselines[baseline_id]
 
+    def register_target_with_maintenance_window(
+        self,
+        window_id: str,
+        resource_type: str,
+        targets: List[Dict[str, Any]],
+        owner_information: Optional[str],
+        name: Optional[str],
+        description: Optional[str],
+    ) -> str:
+        """
+        Registers a target with a maintenance window. No error handling has been implemented yet.
+        """
+        window = self.get_maintenance_window(window_id)
+
+        target = FakeMaintenanceWindowTarget(
+            window_id,
+            resource_type,
+            targets,
+            owner_information=owner_information,
+            name=name,
+            description=description,
+        )
+        window.targets[target.window_target_id] = target
+        return target.window_target_id
+
+    def deregister_target_from_maintenance_window(
+        self, window_id: str, window_target_id: str
+    ) -> None:
+        """
+        Deregisters a target from a maintenance window. No error handling has been implemented yet.
+        """
+        window = self.get_maintenance_window(window_id)
+        del window.targets[window_target_id]
+
+    def describe_maintenance_window_targets(
+        self, window_id: str, filters: Optional[List[Dict[str, Any]]]
+    ) -> List[FakeMaintenanceWindowTarget]:
+        """
+        Describes all targets for a maintenance window. No error handling has been implemented yet.
+        """
+        window = self.get_maintenance_window(window_id)
+        targets = [
+            target
+            for target in window.targets.values()
+            if _maintenance_window_target_filter_match(filters, target)
+        ]
+        return targets
+
 
 ssm_backends = BackendDict(SimpleSystemManagerBackend, "ssm")
diff --git a/moto/ssm/responses.py b/moto/ssm/responses.py
index b53ba3c5c..3f757f03d 100644
--- a/moto/ssm/responses.py
+++ b/moto/ssm/responses.py
@@ -2,7 +2,7 @@ import json
 from typing import Any, Dict, Tuple, Union
 
 from moto.core.responses import BaseResponse
-from .exceptions import ValidationException
+from .exceptions import ValidationException, ParameterAlreadyExists
 from .models import ssm_backends, SimpleSystemManagerBackend
 
 
@@ -286,11 +286,7 @@ class SimpleSystemManagerResponse(BaseResponse):
         )
 
         if result is None:
-            error = {
-                "__type": "ParameterAlreadyExists",
-                "message": f"Parameter {name} already exists.",
-            }
-            return json.dumps(error), dict(status=400)
+            raise ParameterAlreadyExists
 
         response = {"Version": result}
         return json.dumps(response)
@@ -441,6 +437,36 @@ class SimpleSystemManagerResponse(BaseResponse):
         window = self.ssm_backend.get_maintenance_window(window_id)
         return json.dumps(window.to_json())
 
+    def register_target_with_maintenance_window(self) -> str:
+        window_target_id = self.ssm_backend.register_target_with_maintenance_window(
+            window_id=self._get_param("WindowId"),
+            resource_type=self._get_param("ResourceType"),
+            targets=self._get_param("Targets"),
+            owner_information=self._get_param("OwnerInformation"),
+            name=self._get_param("Name"),
+            description=self._get_param("Description"),
+        )
+        return json.dumps({"WindowTargetId": window_target_id})
+
+    def describe_maintenance_window_targets(self) -> str:
+        window_id = self._get_param("WindowId")
+        filters = self._get_param("Filters", [])
+        targets = [
+            target.to_json()
+            for target in self.ssm_backend.describe_maintenance_window_targets(
+                window_id, filters
+            )
+        ]
+        return json.dumps({"Targets": targets})
+
+    def deregister_target_from_maintenance_window(self) -> str:
+        window_id = self._get_param("WindowId")
+        window_target_id = self._get_param("WindowTargetId")
+        self.ssm_backend.deregister_target_from_maintenance_window(
+            window_id, window_target_id
+        )
+        return "{}"
+
     def describe_maintenance_windows(self) -> str:
         filters = self._get_param("Filters", None)
         windows = [


Test patch:
diff --git a/tests/test_ec2/test_route_tables.py b/tests/test_ec2/test_route_tables.py
index 70b70bd67..a20e5b68c 100644
--- a/tests/test_ec2/test_route_tables.py
+++ b/tests/test_ec2/test_route_tables.py
@@ -643,6 +643,18 @@ def test_routes_replace():
     ex.value.response["ResponseMetadata"].should.have.key("RequestId")
     ex.value.response["Error"]["Code"].should.equal("InvalidRouteTableID.NotFound")
 
+    with pytest.raises(ClientError) as ex:
+        client.replace_route(
+            RouteTableId=main_route_table.id,
+            DestinationCidrBlock="1.1.1.1/32",
+            NetworkInterfaceId=eni.id,
+        )
+    ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.value.response["ResponseMetadata"].should.have.key("RequestId")
+    # This should be 'InvalidRoute.NotFound' in line with the delete_route()
+    # equivalent, but for some reason AWS returns InvalidParameterValue instead.
+    ex.value.response["Error"]["Code"].should.equal("InvalidParameterValue")
+
 
 @mock_ec2
 def test_routes_already_exist():
diff --git a/tests/test_ssm/test_ssm_boto3.py b/tests/test_ssm/test_ssm_boto3.py
index daf6a73a9..6c227ee8d 100644
--- a/tests/test_ssm/test_ssm_boto3.py
+++ b/tests/test_ssm/test_ssm_boto3.py
@@ -253,7 +253,7 @@ def test_put_parameter(name):
     except botocore.exceptions.ClientError as err:
         err.operation_name.should.equal("PutParameter")
         err.response["Error"]["Message"].should.equal(
-            f"Parameter {name} already exists."
+            "The parameter already exists. To overwrite this value, set the overwrite option in the request to true."
         )
 
     response = client.get_parameters(Names=[name], WithDecryption=False)
@@ -431,6 +431,61 @@ def test_put_parameter_invalid_type():
     )
 
 
+@mock_ssm
+def test_update_parameter():
+    # Setup
+    client = boto3.client("ssm", "us-east-1")
+    param_name = "test_param"
+    updated_value = "UpdatedValue"
+    client.put_parameter(
+        Description="Description",
+        Name=param_name,
+        Type="String",
+        Value="Value",
+    )
+
+    # Execute
+    response = client.put_parameter(
+        Name=param_name,
+        Overwrite=True,
+        Value=updated_value,
+    )
+    new_param = client.get_parameter(Name=param_name)
+
+    # Verify
+    assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
+    assert new_param["Parameter"]["Value"] == updated_value
+
+
+@mock_ssm
+def test_update_parameter_already_exists_error():
+    # Setup
+    client = boto3.client("ssm", "us-east-1")
+    client.put_parameter(
+        Description="Description",
+        Name="Name",
+        Type="String",
+        Value="Value",
+    )
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.put_parameter(
+            Name="Name",
+            Value="UpdatedValue",
+        )
+
+    # Verify
+    ex = exc.value
+    assert ex.operation_name.should.equal("PutParameter")
+    assert ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    assert ex.response["Error"]["Code"] == "ParameterAlreadyExists"
+    assert (
+        ex.response["Error"]["Message"]
+        == "The parameter already exists. To overwrite this value, set the overwrite option in the request to true."
+    )
+
+
 @mock_ssm
 def test_get_parameter():
     client = boto3.client("ssm", region_name="us-east-1")
diff --git a/tests/test_ssm/test_ssm_maintenance_windows.py b/tests/test_ssm/test_ssm_maintenance_windows.py
index 08b3c1ddc..f484a4252 100644
--- a/tests/test_ssm/test_ssm_maintenance_windows.py
+++ b/tests/test_ssm/test_ssm_maintenance_windows.py
@@ -212,3 +212,65 @@ def test_tags():
         ResourceType="MaintenanceWindow", ResourceId=mw_id
     )["TagList"]
     assert tags == [{"Key": "k2", "Value": "v2"}]
+
+
+@mock_ssm
+def test_register_maintenance_window_target():
+    ssm = boto3.client("ssm", region_name="us-east-1")
+
+    resp = ssm.create_maintenance_window(
+        Name="simple-window",
+        Schedule="cron(15 12 * * ? *)",
+        Duration=2,
+        Cutoff=1,
+        AllowUnassociatedTargets=False,
+    )
+    window_id = resp["WindowId"]
+
+    resp = ssm.register_target_with_maintenance_window(
+        WindowId=window_id,
+        ResourceType="INSTANCE",
+        Targets=[{"Key": "tag:Name", "Values": ["my-instance"]}],
+    )
+    resp.should.have.key("WindowTargetId")
+    _id = resp["WindowTargetId"]
+
+    resp = ssm.describe_maintenance_window_targets(
+        WindowId=window_id,
+    )
+    resp.should.have.key("Targets").should.have.length_of(1)
+    resp["Targets"][0].should.have.key("ResourceType").equal("INSTANCE")
+    resp["Targets"][0].should.have.key("WindowTargetId").equal(_id)
+    resp["Targets"][0]["Targets"][0].should.have.key("Key").equal("tag:Name")
+    resp["Targets"][0]["Targets"][0].should.have.key("Values").equal(["my-instance"])
+
+
+@mock_ssm
+def test_deregister_target_from_maintenance_window():
+    ssm = boto3.client("ssm", region_name="us-east-1")
+
+    resp = ssm.create_maintenance_window(
+        Name="simple-window",
+        Schedule="cron(15 12 * * ? *)",
+        Duration=2,
+        Cutoff=1,
+        AllowUnassociatedTargets=False,
+    )
+    window_id = resp["WindowId"]
+
+    resp = ssm.register_target_with_maintenance_window(
+        WindowId=window_id,
+        ResourceType="INSTANCE",
+        Targets=[{"Key": "tag:Name", "Values": ["my-instance"]}],
+    )
+    _id = resp["WindowTargetId"]
+
+    ssm.deregister_target_from_maintenance_window(
+        WindowId=window_id,
+        WindowTargetId=_id,
+    )
+
+    resp = ssm.describe_maintenance_window_targets(
+        WindowId=window_id,
+    )
+    resp.should.have.key("Targets").should.have.length_of(0)