diff --git a/moto/route53/exceptions.py b/moto/route53/exceptions.py index f7c6aefb6..01f5df2ab 100644 --- a/moto/route53/exceptions.py +++ b/moto/route53/exceptions.py @@ -89,6 +89,28 @@ class HostedZoneNotEmpty(Route53ClientError): self.content_type = "text/xml" +class PublicZoneVPCAssociation(Route53ClientError): + """Public hosted zone can't be associated.""" + + code = 400 + + def __init__(self): + message = "You're trying to associate a VPC with a public hosted zone. Amazon Route 53 doesn't support associating a VPC with a public hosted zone." + super().__init__("PublicZoneVPCAssociation", message) + self.content_type = "text/xml" + + +class LastVPCAssociation(Route53ClientError): + """Last VPC can't be disassociate.""" + + code = 400 + + def __init__(self): + message = "The VPC that you're trying to disassociate from the private hosted zone is the last VPC that is associated with the hosted zone. Amazon Route 53 doesn't support disassociating the last VPC from a hosted zone." + super().__init__("LastVPCAssociation", message) + self.content_type = "text/xml" + + class NoSuchQueryLoggingConfig(Route53ClientError): """Query log config does not exist.""" diff --git a/moto/route53/models.py b/moto/route53/models.py index 7dd764bbd..7f81edc82 100644 --- a/moto/route53/models.py +++ b/moto/route53/models.py @@ -11,11 +11,13 @@ from jinja2 import Template from moto.route53.exceptions import ( HostedZoneNotEmpty, InvalidInput, + LastVPCAssociation, NoSuchCloudWatchLogsLogGroup, NoSuchDelegationSet, NoSuchHealthCheck, NoSuchHostedZone, NoSuchQueryLoggingConfig, + PublicZoneVPCAssociation, QueryLoggingConfigAlreadyExists, ) from moto.core import BaseBackend, BaseModel, CloudFormationModel, get_account_id @@ -236,19 +238,14 @@ class FakeZone(CloudFormationModel): name, id_, private_zone, - vpcid=None, - vpcregion=None, comment=None, delegation_set=None, ): self.name = name self.id = id_ + self.vpcs = [] if comment is not None: self.comment = comment - if vpcid is not None: - self.vpcid = vpcid - if vpcregion is not None: - self.vpcregion = vpcregion self.private_zone = private_zone self.rrsets = [] self.delegation_set = delegation_set @@ -287,6 +284,19 @@ class FakeZone(CloudFormationModel): if record_set.set_identifier != set_identifier ] + def add_vpc(self, vpc_id, vpc_region): + vpc = {} + if vpc_id is not None: + vpc["vpc_id"] = vpc_id + if vpc_region is not None: + vpc["vpc_region"] = vpc_region + if vpc_id or vpc_region: + self.vpcs.append(vpc) + return vpc + + def delete_vpc(self, vpc_id): + self.vpcs = [vpc for vpc in self.vpcs if vpc["vpc_id"] != vpc_id] + def get_record_sets(self, start_type, start_name): def predicate(rrset): rrset_name_reversed = reverse_domain_name(rrset.name) @@ -420,8 +430,6 @@ class Route53Backend(BaseBackend): name, new_id, private_zone=private_zone, - vpcid=vpcid, - vpcregion=vpcregion, comment=comment, delegation_set=delegation_set, ) @@ -433,6 +441,7 @@ class Route53Backend(BaseBackend): "Type": "NS", } new_zone.add_rrset(record_set) + new_zone.add_vpc(vpcid, vpcregion) self.zones[new_id] = new_zone return new_zone @@ -440,6 +449,20 @@ class Route53Backend(BaseBackend): # check if hosted zone exists self.get_hosted_zone(zone_id) + def associate_vpc(self, zone_id, vpcid, vpcregion): + zone = self.get_hosted_zone(zone_id) + if not zone.private_zone: + raise PublicZoneVPCAssociation() + zone.add_vpc(vpcid, vpcregion) + return zone + + def disassociate_vpc(self, zone_id, vpcid): + zone = self.get_hosted_zone(zone_id) + if len(zone.vpcs) <= 1: + raise LastVPCAssociation() + zone.delete_vpc(vpcid) + return zone + def change_tags_for_resource(self, resource_id, tags): if "Tag" in tags: if isinstance(tags["Tag"], list): @@ -545,15 +568,16 @@ class Route53Backend(BaseBackend): for zone in self.list_hosted_zones(): if zone.private_zone is True: this_zone = self.get_hosted_zone(zone.id) - if this_zone.vpcid == vpc_id: - this_id = f"/hostedzone/{zone.id}" - zone_list.append( - { - "HostedZoneId": this_id, - "Name": zone.name, - "Owner": {"OwningAccount": get_account_id()}, - } - ) + for vpc in this_zone.vpcs: + if vpc["vpc_id"] == vpc_id: + this_id = f"/hostedzone/{zone.id}" + zone_list.append( + { + "HostedZoneId": this_id, + "Name": zone.name, + "Owner": {"OwningAccount": get_account_id()}, + } + ) return zone_list @@ -581,6 +605,11 @@ class Route53Backend(BaseBackend): raise HostedZoneNotEmpty() return self.zones.pop(id_.replace("/hostedzone/", ""), None) + def update_hosted_zone_comment(self, id_, comment): + zone = self.get_hosted_zone(id_) + zone.comment = comment + return zone + def create_health_check(self, caller_reference, health_check_args): health_check_id = str(uuid.uuid4()) health_check = HealthCheck(health_check_id, caller_reference, health_check_args) diff --git a/moto/route53/responses.py b/moto/route53/responses.py index 0563be60a..0bff963b2 100644 --- a/moto/route53/responses.py +++ b/moto/route53/responses.py @@ -114,6 +114,14 @@ class Route53(BaseResponse): elif request.method == "DELETE": route53_backend.delete_hosted_zone(zoneid) return 200, headers, DELETE_HOSTED_ZONE_RESPONSE + elif request.method == "POST": + elements = xmltodict.parse(self.body) + comment = elements.get("UpdateHostedZoneCommentRequest", {}).get( + "Comment", None + ) + zone = route53_backend.update_hosted_zone_comment(zoneid, comment) + template = Template(UPDATE_HOSTED_ZONE_COMMENT_RESPONSE) + return 200, headers, template.render(zone=zone) def get_dnssec_response(self, request, full_url, headers): # returns static response @@ -129,6 +137,43 @@ class Route53(BaseResponse): route53_backend.get_dnssec(zoneid) return 200, headers, GET_DNSSEC + def associate_vpc_response(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + + parsed_url = urlparse(full_url) + zoneid = parsed_url.path.rstrip("/").rsplit("/", 2)[1] + + elements = xmltodict.parse(self.body) + comment = vpc = elements.get("AssociateVPCWithHostedZoneRequest", {}).get( + "Comment", {} + ) + vpc = elements.get("AssociateVPCWithHostedZoneRequest", {}).get("VPC", {}) + vpcid = vpc.get("VPCId", None) + vpcregion = vpc.get("VPCRegion", None) + + route53_backend.associate_vpc(zoneid, vpcid, vpcregion) + + template = Template(ASSOCIATE_VPC_RESPONSE) + return 200, headers, template.render(comment=comment) + + def disassociate_vpc_response(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + + parsed_url = urlparse(full_url) + zoneid = parsed_url.path.rstrip("/").rsplit("/", 2)[1] + + elements = xmltodict.parse(self.body) + comment = vpc = elements.get("DisassociateVPCFromHostedZoneRequest", {}).get( + "Comment", {} + ) + vpc = elements.get("DisassociateVPCFromHostedZoneRequest", {}).get("VPC", {}) + vpcid = vpc.get("VPCId", None) + + route53_backend.disassociate_vpc(zoneid, vpcid) + + template = Template(DISASSOCIATE_VPC_RESPONSE) + return 200, headers, template.render(comment) + def rrset_response(self, request, full_url, headers): self.setup_class(request, full_url, headers) @@ -544,10 +589,12 @@ GET_HOSTED_ZONE_RESPONSE = """ {{ health_check.to_xml() }} """ + +UPDATE_HOSTED_ZONE_COMMENT_RESPONSE = """ + + + + {% if zone.comment %} + {{ zone.comment }} + {% endif %} + {{ 'true' if zone.private_zone else 'false' }} + + /hostedzone/{{ zone.id }} + {{ zone.name }} + {{ zone.rrsets|count }} + + +""" + +ASSOCIATE_VPC_RESPONSE = """ + + + {{ comment }} + /change/a1b2c3d4 + INSYNC + 2017-03-31T01:36:41.958Z + + +""" + +DISASSOCIATE_VPC_RESPONSE = """ + + + {{ comment }} + /change/a1b2c3d4 + INSYNC + 2017-03-31T01:36:41.958Z + + +""" diff --git a/moto/route53/urls.py b/moto/route53/urls.py index 0a5b85eb0..386d90e92 100644 --- a/moto/route53/urls.py +++ b/moto/route53/urls.py @@ -17,6 +17,8 @@ url_paths = { r"{0}/(?P[\d_-]+)/hostedzone/(?P[^/]+)$": Route53().get_or_delete_hostzone_response, r"{0}/(?P[\d_-]+)/hostedzone/(?P[^/]+)/rrset/?$": Route53().rrset_response, r"{0}/(?P[\d_-]+)/hostedzone/(?P[^/]+)/dnssec/?$": Route53().get_dnssec_response, + r"{0}/(?P[\d_-]+)/hostedzone/(?P[^/]+)/associatevpc/?$": Route53().associate_vpc_response, + r"{0}/(?P[\d_-]+)/hostedzone/(?P[^/]+)/disassociatevpc/?$": Route53().disassociate_vpc_response, r"{0}/(?P[\d_-]+)/hostedzonesbyname": Route53().list_hosted_zones_by_name_response, r"{0}/(?P[\d_-]+)/hostedzonesbyvpc": Route53().list_hosted_zones_by_vpc_response, r"{0}/(?P[\d_-]+)/hostedzonecount": Route53().get_hosted_zone_count_response, diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt index 8b09345ff..247b1c76a 100644 --- a/tests/terraformtests/terraform-tests.success.txt +++ b/tests/terraformtests/terraform-tests.success.txt @@ -163,6 +163,17 @@ route53: - TestAccRoute53Record_longTXTrecord - TestAccRoute53Record_doNotAllowOverwrite - TestAccRoute53Record_allowOverwrite + - TestAccRoute53Zone_basic + - TestAccRoute53Zone_disappears + - TestAccRoute53Zone_multiple + - TestAccRoute53Zone_comment + - TestAccRoute53Zone_delegationSetID + - TestAccRoute53Zone_forceDestroy + - TestAccRoute53Zone_ForceDestroy_trailingPeriod + - TestAccRoute53Zone_tags + - TestAccRoute53Zone_VPC_single + - TestAccRoute53Zone_VPC_multiple + - TestAccRoute53Zone_VPC_updates s3: - TestAccS3BucketPolicy - TestAccS3BucketPublicAccessBlock diff --git a/tests/test_route53/test_route53.py b/tests/test_route53/test_route53.py index 1352ba6f8..d9963a266 100644 --- a/tests/test_route53/test_route53.py +++ b/tests/test_route53/test_route53.py @@ -506,11 +506,7 @@ def test_hosted_zone_private_zone_preserved(): hosted_zone = conn.get_hosted_zone(Id=zone_id) hosted_zone["HostedZone"]["Config"]["PrivateZone"].should.equal(True) hosted_zone.should.have.key("VPCs") - hosted_zone["VPCs"].should.have.length_of(1) - hosted_zone["VPCs"][0].should.have.key("VPCId") - hosted_zone["VPCs"][0].should.have.key("VPCRegion") - hosted_zone["VPCs"][0]["VPCId"].should.equal("") - hosted_zone["VPCs"][0]["VPCRegion"].should.equal("") + hosted_zone["VPCs"].should.have.length_of(0) hosted_zones = conn.list_hosted_zones() hosted_zones["HostedZones"].should.have.length_of(2)