Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bulk REST requests #96

Merged
merged 18 commits into from
May 3, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c335b93
fix(api): fix hostname-based abuse check
peterthomassen Mar 26, 2018
7bd585d
refactor(api): add method to convert dict-like RRsets to RRset objects
peterthomassen Mar 29, 2018
3887c6b
fix(api): further improvements to Domain.write_rrsets()
peterthomassen Mar 29, 2018
2c3cb67
feat(api): bulk REST requests, closes #83
peterthomassen Apr 13, 2018
18f9dc8
fix(tests): adjust status codes when changing immutable fields
peterthomassen Mar 29, 2018
bab39a4
feat(dev): disable pdns cache for e2e tests
nils-wisiol Apr 11, 2018
c9640a0
fix(e2e): rewrote dns test interface
nils-wisiol Apr 11, 2018
fd009a6
fix(e2e): various improvements to the e2e DNS test tool
peterthomassen Apr 11, 2018
c312cad
feat(e2e): introduce new test routine for checking RRsets in the DNS
peterthomassen Apr 11, 2018
5e842fb
refactor(e2e): move API tests to api_spec.js
peterthomassen Mar 26, 2018
1d03942
feat(tests): bulk API e2e tests
peterthomassen Mar 27, 2018
9158273
fix(api): relax abuse limits for test scenario
peterthomassen Mar 26, 2018
9e0f5ea
fix(api): improve validation of donation input data
peterthomassen Mar 26, 2018
dc64f00
fix(api): when PATCH'ing, correctly inform about missing type field
peterthomassen Mar 29, 2018
4765e0a
fix(api): disallow tinkering with OPT RRset
peterthomassen Apr 3, 2018
21d43fd
fix(api): disallow generic type format like TYPExxx
peterthomassen Apr 3, 2018
9a09fd6
feat(docs): add description of bulk features
peterthomassen Apr 20, 2018
8060ac3
fix(docs): clarify that NS record values end with a dot
peterthomassen Apr 26, 2018
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
124 changes: 73 additions & 51 deletions api/desecapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import datetime, uuid
Copy link
Contributor

Choose a reason for hiding this comment

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

I did not understand what you meant with 2.) dummy TTL for RRsets up for deletion commit message

Copy link
Member Author

Choose a reason for hiding this comment

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

If you send a PATCH request with empty record contents [], the RRset is supposed to be deleted. This works by sending a similar requests with an empty records field to pdns. However, pdns requires the ttl field to be set, but our PATCH request reasonably does not require that. In this case, we set a dummy value.

(If you leave our the records field from our PATCH request, the RRset is not deleted; you can use such requests to change the TTL only.)

from django.core.validators import MinValueValidator
from rest_framework.authtoken.models import Token
from collections import OrderedDict


class MyUserManager(BaseUserManager):
Expand Down Expand Up @@ -173,12 +174,13 @@ def sync_to_pdns(self):
except Domain.DoesNotExist:
pass
else:
parent.write_rrsets([
rrsets = RRset.plain_to_RRsets([
{'subname': subname, 'type': 'NS', 'ttl': 3600,
'contents': settings.DEFAULT_NS},
{'subname': subname, 'type': 'DS', 'ttl': 60,
'contents': [ds for k in self.keys for ds in k['ds']]}
])
], domain=parent)
parent.write_rrsets(rrsets)
else:
# Zone exists. For the case that pdns knows records that we do not
# (e.g. if a locked account has deleted an RRset), it is necessary
Expand All @@ -205,81 +207,98 @@ def sync_from_pdns(self):
RRset.objects.bulk_create(rrsets)
RR.objects.bulk_create(rrs)

def write_rrsets(self, datas):
rrsets = {}
for data in datas:
rrset = RRset(domain=self, subname=data['subname'],
type=data['type'], ttl=data['ttl'])
rrsets[rrset] = [RR(rrset=rrset, content=content)
for content in data['contents']]
self._write_rrsets(rrsets)

@transaction.atomic
def _write_rrsets(self, rrsets):
# Base queryset for all RRset of the current domain
def write_rrsets(self, rrsets):
# Base queryset for all RRsets of the current domain
rrset_qs = RRset.objects.filter(domain=self)

# Set to check RRset uniqueness
rrsets_seen = set()

# To-do list for non-empty RRsets, indexed by (subname, type)
rrsets_meaty_todo = {}
# We want to return all new, changed, and unchanged RRsets (but not
# deleted ones). We store them here, indexed by (subname, type).
rrsets_to_return = OrderedDict()

# Dictionary of RR lists to send to pdns, indexed by their RRset
rrsets_to_write = {}
# Record contents to send to pdns, indexed by their RRset
rrsets_for_pdns = {}

# Always-false Q object: https://stackoverflow.com/a/35894246/6867099
q_meaty = models.Q(pk__isnull=True)
q_empty = models.Q(pk__isnull=True)

# Determine which RRsets need to be updated or deleted
for rrset, rrs in rrsets.items():
if rrset.domain is not self:
if rrset.domain != self:
raise ValueError('RRset has wrong domain')
if (rrset.subname, rrset.type) in rrsets_seen:
raise ValueError('RRset repeated with same subname and type')
if not all(rr.rrset is rrset for rr in rrs):
if rrs is not None and not all(rr.rrset is rrset for rr in rrs):
raise ValueError('RR has wrong parent RRset')

rrsets_seen.add((rrset.subname, rrset.type))

q = models.Q(subname=rrset.subname, type=rrset.type)
if rrs:
rrsets_meaty_todo[(rrset.subname, rrset.type)] = rrset
if rrs or rrs is None:
rrsets_to_return[(rrset.subname, rrset.type)] = rrset
q_meaty |= q
else:
rrsets_to_write[rrset] = []
# Set TTL so that pdns does not get confused if missing
rrset.ttl = 1
rrsets_for_pdns[rrset] = []
q_empty |= q

# Construct querysets representing RRsets that do (not) have RR
# contents and lock them
qs_meaty = rrset_qs.filter(q_meaty).select_for_update()
qs_empty = rrset_qs.filter(q_empty).select_for_update()

# For existing RRsets, execute TTL updates and/or mark for RR update
rrsets_same_rrs = []
# For existing RRsets, execute TTL updates and/or mark for RR update.
# First, let's create a to-do dict; we'll need it later for new RRsets.
rrsets_with_new_rrs = []
rrsets_meaty_todo = dict(rrsets_to_return)
for rrset in qs_meaty.all():
rrsets_to_return[(rrset.subname, rrset.type)] = rrset

rrset_temp = rrsets_meaty_todo.pop((rrset.subname, rrset.type))
rrs_temp = {rr.content for rr in rrsets[rrset_temp]}
rrs = {rr.content for rr in rrset.records.all()}

partial = rrsets[rrset_temp] is None
if partial:
rrs_temp = rrs
else:
rrs_temp = {rr.content for rr in rrsets[rrset_temp]}

# Take current TTL if none was given
rrset_temp.ttl = rrset_temp.ttl or rrset.ttl

changed_ttl = (rrset_temp.ttl != rrset.ttl)
changed_rrs = (rrs_temp != rrs)
changed_rrs = not partial and (rrs_temp != rrs)

if changed_ttl:
rrset.ttl = rrset_temp.ttl
rrset.save()
if changed_rrs:
rrsets_with_new_rrs.append(rrset)
if changed_ttl or changed_rrs:
rrsets_to_write[rrset] = [RR(rrset=rrset, content=rr_content)
rrsets_for_pdns[rrset] = [RR(rrset=rrset, content=rr_content)
for rr_content in rrs_temp]
if not changed_rrs:
rrsets_same_rrs.append(rrset)

# At this point, rrsets_meaty_todo contains to new, non-empty RRsets
# only. Let's save them. This does not save the associated RRs yet.
# At this point, rrsets_meaty_todo contains new RRsets only, with
# a list of RRs or with None associated.
for key, rrset in list(rrsets_meaty_todo.items()):
rrset.save()
rrsets_to_write[rrset] = rrsets[rrset]
if rrsets[rrset] is None:
# None means "don't change RRs". In the context of a new RRset,
# this really is no-op, and we do not need to return the RRset.
rrsets_to_return.pop((rrset.subname, rrset.type))
else:
# If there are associated RRs, let's save the RRset. This does
# not save the RRs yet.
rrsets_with_new_rrs.append(rrset)
rrset.save()

# In either case, send a request to pdns so that we can take
# advantage of pdns' type validation check (even if no RRs given).
rrsets_for_pdns[rrset] = rrsets[rrset]

# Repeat lock to make sure new RRsets are also locked
rrset_qs.filter(q_meaty).select_for_update()
Expand All @@ -288,15 +307,18 @@ def _write_rrsets(self, rrsets):
qs_empty.delete()

# Update contents of modified RRsets
RR.objects.filter(rrset__in=qs_meaty).exclude(rrset__in=rrsets_same_rrs).delete()
RR.objects.filter(rrset__in=rrsets_with_new_rrs).delete()
RR.objects.bulk_create([rr
for (rrset, rrs) in rrsets_to_write.items()
for rr in rrs
if rrset not in rrsets_same_rrs])
for (rrset, rrs) in rrsets_for_pdns.items()
if rrs and rrset in rrsets_with_new_rrs
for rr in rrs])

# Send RRsets to pdns
if rrsets_to_write and not self.owner.locked:
pdns.set_rrsets(self, rrsets_to_write)
if rrsets_for_pdns and not self.owner.locked:
pdns.set_rrsets(self, rrsets_for_pdns)

# Return RRsets
return list(rrsets_to_return.values())

@transaction.atomic
def delete(self, *args, **kwargs):
Expand All @@ -310,10 +332,7 @@ def delete(self, *args, **kwargs):
else:
rrsets = parent.rrset_set.filter(subname=subname,
type__in=['NS', 'DS']).all()
# Need to go RRset by RRset to trigger pdns sync
# TODO can optimize using write_rrsets()
for rrset in rrsets:
rrset.delete()
parent.write_rrsets({rrset: [] for rrset in rrsets})

# Delete domain
super().delete(*args, **kwargs)
Expand Down Expand Up @@ -387,7 +406,7 @@ class RRset(models.Model, mixins.SetterMixin):
ttl = models.PositiveIntegerField(validators=[MinValueValidator(1)])

_dirty = False
RESTRICTED_TYPES = ('SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM')
RESTRICTED_TYPES = ('SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM', 'OPT')


class Meta:
Expand Down Expand Up @@ -441,13 +460,6 @@ def get_dirties(self):
def name(self):
return '.'.join(filter(None, [self.subname, self.domain.name])) + '.'

@transaction.atomic
def set_rrs(self, contents, sync=True, notify=True):
self.records.all().delete()
self.records.set([RR(content=x) for x in contents], bulk=False)
if sync and not self.domain.owner.locked:
pdns.set_rrset(self, notify=notify)

@transaction.atomic
def delete(self, *args, **kwargs):
# For locked users, we can't easily sync deleted RRsets to pdns later,
Expand All @@ -467,6 +479,16 @@ def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self._dirties = {}

@staticmethod
def plain_to_RRsets(datas, *, domain):
rrsets = {}
for data in datas:
rrset = RRset(domain=domain, subname=data['subname'],
type=data['type'], ttl=data['ttl'])
rrsets[rrset] = [RR(rrset=rrset, content=content)
for content in data['contents']]
return rrsets


class RR(models.Model):
created = models.DateTimeField(auto_now_add=True)
Expand Down
Loading