Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ Storage
(GITHUB-1698)
[Sanjay Santhanam - @Sanjays2402]

DNS
~~~

- [Route53] Fix ``delete_record`` failing with ``RecordDoesNotExistError`` for
records which are part of a multi value record set (e.g. MX). Route53 only
accepts a DELETE changeset which lists every value in the record set, so the
values of the other records in the set are now included, mirroring how
``update_record`` already handles multi value records.
(GITHUB-1831)
[Sanjay Santhanam - @Sanjays2402]

Changes in Apache Libcloud 3.9.1
--------------------------------

Expand Down
96 changes: 93 additions & 3 deletions libcloud/dns/drivers/route53.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,39 @@ def create_record(self, name, zone, type, data, extra=None):
extra=extra,
)

def _with_record_set_metadata(self, record):
# ``_multi_value`` / ``_other_records`` are attached by ``_to_records``,
# so records which did not come from ``list_records`` / ``get_record``
# (e.g. the ones returned by ``create_record`` or
# ``ex_create_multi_value_record``, or user constructed ones) carry no
# information about the rest of their record set. Re-fetch the record
# set in that case so multi value updates and deletes work regardless
# of how the record was obtained.

if "_multi_value" in record.extra:
return record

try:
fetched = self.list_records(zone=record.zone)
except Exception:
return record

for candidate in fetched:
if (
candidate.name == record.name
and candidate.type == record.type
and candidate.data == record.data
):
extra = copy.deepcopy(candidate.extra)
extra.update({k: v for k, v in record.extra.items() if not k.startswith("_")})
record.extra = extra

break

return record

def update_record(self, record, name=None, type=None, data=None, extra=None):
record = self._with_record_set_metadata(record)
name = name or record.name
type = type or record.type
extra = extra or record.extra
Expand Down Expand Up @@ -236,14 +268,72 @@ def update_record(self, record, name=None, type=None, data=None, extra=None):

def delete_record(self, record):
try:
r = record
batch = [("DELETE", r.name, r.type, r.data, r.extra)]
self._post_changeset(record.zone, batch)
r = self._with_record_set_metadata(record)

# Multiple value records need to be handled specially - Route53
# only accepts a DELETE for a record set which lists every value
# in that set, so values for the other records need to be sent
# as well.

if r.extra.get("_multi_value", False) and r.extra.get("_other_records", []):
self._delete_multi_value_record(record=r)
else:
batch = [("DELETE", r.name, r.type, r.data, r.extra)]
self._post_changeset(record.zone, batch)
Comment on lines +278 to +282

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sanjays2402 could you please take a look?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - that was a real gap. A Record from create_record() / ex_create_multi_value_record() (or one built by hand) has no _multi_value / _other_records, so both delete_record() and update_record() would send a single-value changeset and Route53 would reject it.

Pushed 6e2579e. Added _with_record_set_metadata(), called from both update_record() and delete_record(): if _multi_value is absent from record.extra it looks the record set up in the zone and merges the metadata in, keeping any caller-supplied extras (ttl, priority). If the lookup fails or the record is not found it falls back to the old behaviour rather than raising.

I used list_records() rather than get_record() for the lookup, since get_record() only asks Route53 for maxitems=1 and then rejects the result when the returned member is not the one requested - for a multi value MX set that raises RecordDoesNotExistError before the metadata can be read.

New test test_delete_multi_value_record_without_record_set_metadata builds a bare Record with no metadata and asserts the DELETE changeset contains all five MX values. It fails without the driver change (only 1 ASPMX.L.GOOGLE.COM. is emitted). libcloud/test/dns/test_route53.py is 25 passed.

except InvalidChangeBatch:
raise RecordDoesNotExistError(value="", driver=self, record_id=r.id)

return True

def _delete_multi_value_record(self, record):
other_records = record.extra.get("_other_records", [])

attrs = {"xmlns": NAMESPACE}
changeset = ET.Element("ChangeResourceRecordSetsRequest", attrs)
batch = ET.SubElement(changeset, "ChangeBatch")
changes = ET.SubElement(batch, "Changes")

change = ET.SubElement(changes, "Change")
ET.SubElement(change, "Action").text = "DELETE"

rrs = ET.SubElement(change, "ResourceRecordSet")

if record.name:
record_name = record.name + "." + record.zone.domain
else:
record_name = record.zone.domain

ET.SubElement(rrs, "Name").text = record_name
ET.SubElement(rrs, "Type").text = self.RECORD_TYPE_MAP[record.type]
ET.SubElement(rrs, "TTL").text = str(record.extra.get("ttl", "0"))

rrecs = ET.SubElement(rrs, "ResourceRecords")

rrec = ET.SubElement(rrecs, "ResourceRecord")
ET.SubElement(rrec, "Value").text = self._to_record_value(record.data, record.extra)

for other_record in other_records:
rrec = ET.SubElement(rrecs, "ResourceRecord")
ET.SubElement(rrec, "Value").text = self._to_record_value(
other_record["data"], other_record.get("extra", {})
)

uri = API_ROOT + "hostedzone/" + record.zone.id + "/rrset"
data = ET.tostring(changeset)
self.connection.set_context({"zone_id": record.zone.id})
response = self.connection.request(uri, method="POST", data=data)

return response.status == httplib.OK

def _to_record_value(self, data, extra):
# "priority" is parsed out of the value by _to_record, so it needs to
# be put back to reconstruct the value Route53 stores.

if extra and "priority" in extra:
return "{} {}".format(extra["priority"], data)

return data

def ex_create_multi_value_record(self, name, zone, type, data, extra=None):
"""
Create a record with multiple values with a single call.
Expand Down
89 changes: 89 additions & 0 deletions libcloud/test/dns/test_route53.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import re
import sys
import unittest

from libcloud.test import MockHttp
from libcloud.dns.base import Record
from libcloud.dns.types import RecordType, ZoneDoesNotExistError, RecordDoesNotExistError
from libcloud.utils.py3 import httplib
from libcloud.test.secrets import DNS_PARAMS_ROUTE53
Expand Down Expand Up @@ -284,6 +286,93 @@ def test_delete_record(self):
status = self.driver.delete_record(record=record)
self.assertTrue(status)

def test_delete_multi_value_record(self):
zone = self.driver.list_zones()[0]
records = [r for r in self.driver.list_records(zone=zone) if r.type == RecordType.MX]
record = records[0]

sent = {}
original_request = self.driver.connection.request

def record_request(uri, *args, **kwargs):
if kwargs.get("method") == "POST":
sent["data"] = kwargs.get("data")

return original_request(uri, *args, **kwargs)

self.driver.connection.request = record_request
status = self.driver.delete_record(record=record)
self.assertTrue(status)

data = sent["data"]

if not isinstance(data, str):
data = data.decode("utf-8")

# Route53 only accepts a DELETE which lists every value in the record
# set, so all the values need to be included in the changeset.
values = re.findall(r"<Value>(.*?)</Value>", data)
self.assertEqual(
values,
[
"1 ASPMX.L.GOOGLE.COM.",
"5 ALT1.ASPMX.L.GOOGLE.COM.",
"5 ALT2.ASPMX.L.GOOGLE.COM.",
"10 ASPMX2.GOOGLEMAIL.COM.",
"10 ASPMX3.GOOGLEMAIL.COM.",
],
)

def test_delete_multi_value_record_without_record_set_metadata(self):
# Records which did not come from list_records()/get_record() (e.g. the
# ones returned by create_record()) carry no _multi_value metadata, so
# the record set has to be re-fetched for the DELETE to be valid.
zone = self.driver.list_zones()[0]
listed = [r for r in self.driver.list_records(zone=zone) if r.type == RecordType.MX][0]

record = Record(
id=listed.id,
name=listed.name,
type=listed.type,
data=listed.data,
zone=zone,
driver=self.driver,
ttl=listed.extra.get("ttl"),
extra={"ttl": listed.extra.get("ttl"), "priority": listed.extra.get("priority")},
)

sent = {}
original_request = self.driver.connection.request

def record_request(uri, *args, **kwargs):
if kwargs.get("method") == "POST":
sent["data"] = kwargs.get("data")

return original_request(uri, *args, **kwargs)

self.driver.connection.request = record_request
status = self.driver.delete_record(record=record)
self.assertTrue(status)

data = sent["data"]

if not isinstance(data, str):
data = data.decode("utf-8")

values = re.findall(r"<Value>(.*?)</Value>", data)
self.assertEqual(
sorted(values),
sorted(
[
"1 ASPMX.L.GOOGLE.COM.",
"5 ALT1.ASPMX.L.GOOGLE.COM.",
"5 ALT2.ASPMX.L.GOOGLE.COM.",
"10 ASPMX2.GOOGLEMAIL.COM.",
"10 ASPMX3.GOOGLEMAIL.COM.",
]
),
)

def test_delete_record_does_not_exist(self):
zone = self.driver.list_zones()[0]
record = self.driver.list_records(zone=zone)[0]
Expand Down