diff --git a/moto/ds/models.py b/moto/ds/models.py index 424f008c4..c03a2f8ea 100644 --- a/moto/ds/models.py +++ b/moto/ds/models.py @@ -79,26 +79,31 @@ class Directory(BaseModel): # pylint: disable=too-many-instance-attributes self.stage = "Active" self.launch_time = datetime.now(timezone.utc).isoformat() self.stage_last_updated_date_time = datetime.now(timezone.utc).isoformat() - # Create a security group and ENI, returning the IPs for the ENI. - subnet_ips = self.create_eni() - if self.directory_type != "ADConnector": - self.dns_ip_addrs = subnet_ips - else: + if self.directory_type == "ADConnector": + self.security_group_id = self.create_security_group( + self.connect_settings["VpcId"] + ) + self.eni_ids, self.subnet_ips = self.create_eni( + self.security_group_id, self.connect_settings["SubnetIds"] + ) + self.connect_settings["SecurityGroupId"] = self.security_group_id + self.connect_settings["ConnectIps"] = self.subnet_ips self.dns_ip_addrs = self.connect_settings["CustomerDnsIps"] - self.connect_settings["ConnectIps"] = subnet_ips - def create_eni(self): - """Return IP addrs after creating an ENI for each subnet.""" - if self.vpc_settings: - vpc_id = self.vpc_settings["VpcId"] - subnet_ids = self.vpc_settings["SubnetIds"] else: - vpc_id = self.connect_settings["VpcId"] - subnet_ids = self.connect_settings["SubnetIds"] + self.security_group_id = self.create_security_group( + self.vpc_settings["VpcId"] + ) + self.eni_ids, self.subnet_ips = self.create_eni( + self.security_group_id, self.vpc_settings["SubnetIds"] + ) + self.vpc_settings["SecurityGroupId"] = self.security_group_id + self.dns_ip_addrs = self.subnet_ips - # Need a security group for the ENI. - security_group = ec2_backends[self.region].create_security_group( + def create_security_group(self, vpc_id): + """Create security group for the network interface.""" + security_group_info = ec2_backends[self.region].create_security_group( name=f"{self.directory_id}_controllers", description=( f"AWS created security group for {self.directory_id} " @@ -106,17 +111,31 @@ class Directory(BaseModel): # pylint: disable=too-many-instance-attributes ), vpc_id=vpc_id, ) + return security_group_info.id - ip_addrs = [] + def delete_security_group(self): + """Delete the given security group.""" + ec2_backends[self.region].delete_security_group(group_id=self.security_group_id) + + def create_eni(self, security_group_id, subnet_ids): + """Return ENI ids and primary addresses created for each subnet.""" + eni_ids = [] + subnet_ips = [] for subnet_id in subnet_ids: eni_info = ec2_backends[self.region].create_network_interface( subnet=subnet_id, private_ip_address=None, - group_ids=[security_group.id], + group_ids=[security_group_id], description=f"AWS created network interface for {self.directory_id}", ) - ip_addrs.append(eni_info.private_ip_address) - return ip_addrs + eni_ids.append(eni_info.id) + subnet_ips.append(eni_info.private_ip_address) + return eni_ids, subnet_ips + + def delete_eni(self): + """Delete ENI for each subnet and the security group.""" + for eni_id in self.eni_ids: + ec2_backends[self.region].delete_network_interface(eni_id) def update_alias(self, alias): """Change default alias to given alias.""" @@ -127,26 +146,37 @@ class Directory(BaseModel): # pylint: disable=too-many-instance-attributes """Enable/disable sso based on whether new_state is True or False.""" self.sso_enabled = new_state - def to_json(self): - """Convert the attributes into json with CamelCase tags.""" - replacement_keys = {"directory_type": "Type"} - exclude_items = ["password"] + def to_dict(self): + """Create a dictionary of attributes for Directory.""" + attributes = { + "AccessUrl": self.access_url, + "Alias": self.alias, + "DirectoryId": self.directory_id, + "DesiredNumberOfDomainControllers": self.desired_number_of_domain_controllers, + "DnsIpAddrs": self.dns_ip_addrs, + "LaunchTime": self.launch_time, + "Name": self.name, + "SsoEnabled": self.sso_enabled, + "Stage": self.stage, + "StageLastUpdatedDateTime": self.stage_last_updated_date_time, + "Type": self.directory_type, + } - json_result = {} - for item, value in self.__dict__.items(): - # Discard empty strings, but allow values set to False or zero. - if value == "" or item in exclude_items: - continue + if self.edition: + attributes["Edition"] = self.edition + if self.size: + attributes["Size"] = self.size + if self.short_name: + attributes["ShortName"] = self.short_name + if self.description: + attributes["Description"] = self.description - if item in replacement_keys: - json_result[replacement_keys[item]] = value - else: - new_tag = "".join(x.title() for x in item.split("_")) - json_result[new_tag] = value - - if json_result["ConnectSettings"]: - json_result["ConnectSettings"]["CustomerDnsIps"] = None - return json_result + if self.vpc_settings: + attributes["VpcSettings"] = self.vpc_settings + else: + attributes["ConnectSettings"] = self.connect_settings + attributes["ConnectSettings"]["CustomerDnsIps"] = None + return attributes class DirectoryServiceBackend(BaseBackend): @@ -394,6 +424,8 @@ class DirectoryServiceBackend(BaseBackend): def delete_directory(self, directory_id): """Delete directory with the matching ID.""" self._validate_directory_id(directory_id) + self.directories[directory_id].delete_eni() + self.directories[directory_id].delete_security_group() self.tagger.delete_all_tags_for_resource(directory_id) self.directories.pop(directory_id) return directory_id diff --git a/moto/ds/responses.py b/moto/ds/responses.py index 7c1139d70..9d852a965 100644 --- a/moto/ds/responses.py +++ b/moto/ds/responses.py @@ -97,13 +97,13 @@ class DirectoryServiceResponse(BaseResponse): next_token = self._get_param("NextToken") limit = self._get_int_param("Limit") try: - (descriptions, next_token) = self.ds_backend.describe_directories( + (directories, next_token) = self.ds_backend.describe_directories( directory_ids, next_token=next_token, limit=limit ) except InvalidToken as exc: raise InvalidNextTokenException() from exc - response = {"DirectoryDescriptions": [x.to_json() for x in descriptions]} + response = {"DirectoryDescriptions": [x.to_dict() for x in directories]} if next_token: response["NextToken"] = next_token return json.dumps(response) diff --git a/moto/route53resolver/models.py b/moto/route53resolver/models.py index d573fd5f8..190837c74 100644 --- a/moto/route53resolver/models.py +++ b/moto/route53resolver/models.py @@ -187,6 +187,7 @@ class ResolverEndpoint(BaseModel): # pylint: disable=too-many-instance-attribut # NOTE; This currently doesn't reflect IPv6 addresses. self.subnets = self._build_subnet_info() + self.eni_ids = self.create_eni() self.ip_address_count = len(ip_addresses) self.host_vpc_id = self._vpc_id_from_subnet() @@ -232,9 +233,10 @@ class ResolverEndpoint(BaseModel): # pylint: disable=too-many-instance-attribut def create_eni(self): """Create a VPC ENI for each combo of AZ, subnet and IP.""" + eni_ids = [] for subnet, ip_info in self.subnets.items(): for ip_addr, eni_id in ip_info.items(): - ec2_backends[self.region].create_network_interface( + eni_info = ec2_backends[self.region].create_network_interface( description=f"Route 53 Resolver: {self.id}:{eni_id}", group_ids=self.security_group_ids, interface_type="interface", @@ -244,6 +246,13 @@ class ResolverEndpoint(BaseModel): # pylint: disable=too-many-instance-attribut ], subnet=subnet, ) + eni_ids.append(eni_info.id) + return eni_ids + + def delete_eni(self): + """Delete the VPC ENI created for the subnet and IP combos.""" + for eni_id in self.eni_ids: + ec2_backends[self.region].delete_network_interface(eni_id) def description(self): """Return a dictionary of relevant info for this resolver endpoint.""" @@ -466,7 +475,6 @@ class Route53ResolverBackend(BaseBackend): ip_addresses, name, ) - resolver_endpoint.create_eni() self.resolver_endpoints[endpoint_id] = resolver_endpoint self.tagger.tag_resource(resolver_endpoint.arn, tags or []) @@ -593,6 +601,7 @@ class Route53ResolverBackend(BaseBackend): self.tagger.delete_all_tags_for_resource(resolver_endpoint_id) resolver_endpoint = self.resolver_endpoints.pop(resolver_endpoint_id) + resolver_endpoint.delete_eni() resolver_endpoint.status = "DELETING" resolver_endpoint.status_message = resolver_endpoint.status_message.replace( "Successfully created", "Deleting" diff --git a/tests/test_ds/test_ds.py b/tests/test_ds/test_ds.py index e70d35f72..b7112f81c 100644 --- a/tests/test_ds/test_ds.py +++ b/tests/test_ds/test_ds.py @@ -36,6 +36,16 @@ def test_ds_delete_directory(): result = client.delete_directory(DirectoryId=directory_id) assert result["DirectoryId"] == directory_id + # Verify there are no dictionaries, network interfaces or associated + # security groups. + result = client.describe_directories() + assert len(result["DirectoryDescriptions"]) == 0 + result = ec2_client.describe_network_interfaces() + assert len(result["NetworkInterfaces"]) == 0 + result = ec2_client.describe_security_groups() + for group in result["SecurityGroups"]: + assert "directory controllers" not in group["Description"] + # Attempt to delete a non-existent directory. nonexistent_id = f"d-{get_random_hex(10)}" with pytest.raises(ClientError) as exc: @@ -116,6 +126,7 @@ def test_ds_describe_directories(): assert dir_info["Type"] == "SimpleAD" assert dir_info["VpcSettings"]["VpcId"].startswith("vpc-") assert len(dir_info["VpcSettings"]["SubnetIds"]) == 2 + assert dir_info["VpcSettings"]["SecurityGroupId"].startswith("sg-") assert len(dir_info["DnsIpAddrs"]) == 2 assert "NextToken" not in result diff --git a/tests/test_route53resolver/test_route53resolver_endpoint.py b/tests/test_route53resolver/test_route53resolver_endpoint.py index 323aaef3c..cac6ac626 100644 --- a/tests/test_route53resolver/test_route53resolver_endpoint.py +++ b/tests/test_route53resolver/test_route53resolver_endpoint.py @@ -415,6 +415,12 @@ def test_route53resolver_delete_resolver_endpoint(): assert "Deleting" in endpoint["StatusMessage"] assert endpoint["CreationTime"] == created_endpoint["CreationTime"] + # Verify there are no endpoints or no network interfaces. + response = client.list_resolver_endpoints() + assert len(response["ResolverEndpoints"]) == 0 + result = ec2_client.describe_network_interfaces() + assert len(result["NetworkInterfaces"]) == 0 + @mock_ec2 @mock_route53resolver