← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/maas/lease-model into lp:maas

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/lease-model into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jtv/maas/lease-model/+merge/114235

This has been discussed in the team call.  A new model, maasserver.DHCPLease, keeps track of which IP addresses a node-group worker's DHCP server has leased to which MACs.  We couldn't just add this to Node or MACAddress, since not all of the machines may be known to MAAS.

In particular, this lets us capture a lease handed to a node as it booted up for enlistment.  Once the node is accepted into the MAAS and gets a hostname assigned, we can start set the wheels in motion to publish the hostname in DNS, mapping to the IP address stored in the new table.

You'll also find the code here to update all our knowledge of a node group's leases at once.  To minimize database churn, the model manager is careful not to update or replace unchanged records.  On the other hand, to keep things simple, no attempt is made to model MACs moving to different IP addresses or IP addresses moving to different MACs.  The algorithm for doing all updates is suprisingly simple:

1. Delete any leases from the database that aren't reported in the latest update.

2. Find out which leases are in the latest update but not yet in the database.

3. Add those new leases to the database.

Due to MVCC, this is actually quite similar to what would have happened inside the database if we updated a record instead of deleting one and adding another.

The sequence of the steps avoids unique violations: if an IP address moves to a new MAC, its old lease will already be gone.  This also means that there will be at most one lease for any IP address at any given time.  And this in turn means that we don't need to check MACs to see which leases are new: by that point in the algorithm, if the same IP was ever associated with a different MAC, that association will already have been deleted.

The really hard part of this branch was coming to grips with Django's database access API.  Manager.raw() is nowhere near as raw as it sounds.  I ended up having to use cursors instead.


Jeroen
-- 
https://code.launchpad.net/~jtv/maas/lease-model/+merge/114235
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/lease-model into lp:maas.
=== added file 'src/maasserver/migrations/0012_DHCPLease.py'
--- src/maasserver/migrations/0012_DHCPLease.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/migrations/0012_DHCPLease.py	2012-07-10 17:41:23 +0000
@@ -0,0 +1,162 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding model 'DHCPLease'
+        db.create_table(u'maasserver_dhcplease', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('nodegroup', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['maasserver.NodeGroup'])),
+            ('ip', self.gf('django.db.models.fields.IPAddressField')(unique=True, max_length=15)),
+            ('mac', self.gf('maasserver.fields.MACAddressField')()),
+        ))
+        db.send_create_signal(u'maasserver', ['DHCPLease'])
+
+
+    def backwards(self, orm):
+        
+        # Deleting model 'DHCPLease'
+        db.delete_table(u'maasserver_dhcplease')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        u'maasserver.config': {
+            'Meta': {'object_name': 'Config'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
+        },
+        u'maasserver.dhcplease': {
+            'Meta': {'object_name': 'DHCPLease'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
+            'mac': ('maasserver.fields.MACAddressField', [], {}),
+            'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"})
+        },
+        u'maasserver.filestorage': {
+            'Meta': {'object_name': 'FileStorage'},
+            'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}),
+            'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '200'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        u'maasserver.macaddress': {
+            'Meta': {'object_name': 'MACAddress'},
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
+            'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {})
+        },
+        u'maasserver.node': {
+            'Meta': {'object_name': 'Node'},
+            'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386'", 'max_length': '10'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
+            'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
+            'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
+            'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
+            'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-bac4d9e4-ca82-11e1-896b-002608dc6120'", 'unique': 'True', 'max_length': '41'}),
+            'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {})
+        },
+        u'maasserver.nodegroup': {
+            'Meta': {'object_name': 'NodeGroup'},
+            'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
+            'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'}),
+            'broadcast_ip': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip_range_high': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
+            'ip_range_low': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
+            'name': ('django.db.models.fields.CharField', [], {'default': "u''", 'unique': 'True', 'max_length': '80'}),
+            'router_ip': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}),
+            'subnet_mask': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'worker_ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'})
+        },
+        u'maasserver.sshkey': {
+            'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'},
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.TextField', [], {}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        u'maasserver.userprofile': {
+            'Meta': {'object_name': 'UserProfile'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+        },
+        'piston.consumer': {
+            'Meta': {'object_name': 'Consumer'},
+            'description': ('django.db.models.fields.TextField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"})
+        },
+        'piston.token': {
+            'Meta': {'object_name': 'Token'},
+            'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
+            'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1341919862L'}),
+            'token_type': ('django.db.models.fields.IntegerField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}),
+            'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
+        }
+    }
+
+    complete_apps = ['maasserver']

=== modified file 'src/maasserver/models/__init__.py'
--- src/maasserver/models/__init__.py	2012-06-29 12:52:27 +0000
+++ src/maasserver/models/__init__.py	2012-07-10 17:41:23 +0000
@@ -12,6 +12,7 @@
 __metaclass__ = type
 __all__ = [
     'Config',
+    'DHCPLease',
     'FileStorage',
     'logger',
     'MACAddress',
@@ -29,6 +30,7 @@
 from django.db.models.signals import post_save
 from maasserver.enum import NODE_PERMISSION
 from maasserver.models.config import Config
+from maasserver.models.dhcplease import DHCPLease
 from maasserver.models.filestorage import FileStorage
 from maasserver.models.macaddress import MACAddress
 from maasserver.models.node import Node
@@ -45,7 +47,9 @@
 
 # Suppress warning about symbols being imported, but only used for
 # export in __all__.
-ignore_unused(Config, FileStorage, MACAddress, NodeGroup, SSHKey, UserProfile)
+ignore_unused(
+    Config, DHCPLease, FileStorage, MACAddress, NodeGroup, SSHKey,
+    UserProfile)
 
 
 # Connect the 'create_user' method to the post save signal of User.

=== added file 'src/maasserver/models/dhcplease.py'
--- src/maasserver/models/dhcplease.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/models/dhcplease.py	2012-07-10 17:41:23 +0000
@@ -0,0 +1,125 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Node IP/MAC mappings as leased from the workers' DHCP servers."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    'DHCPLease',
+    ]
+
+
+from django.db import connection
+from django.db.models import (
+    ForeignKey,
+    IPAddressField,
+    Manager,
+    Model,
+    )
+from maasserver import DefaultMeta
+from maasserver.fields import MACAddressField
+from maasserver.models.cleansave import CleanSave
+
+
+def sqlify_ip_mac_pairs(pairs):
+    """Return an SQL string representing a sequence of IP/MAC pairs."""
+    return ", ".join("('%s', '%s')" % pair for pair in pairs)
+
+
+def sqlify_nodegroup_ip_mac_pairs(nodegroup, pairs):
+    """Return an SQL string representing IP/MAC pairs for a nodegroup."""
+    return ", ".join(
+        "(%s, '%s', '%s')" % (nodegroup.id, ip, mac)
+        for ip, mac in pairs)
+
+
+class DHCPLeaseManager(Manager):
+    """Utility that manages :class:`DHCPLease` objects.
+
+    This will be a large and busy part of the database.  Try to perform
+    operations in bulk, using this manager class, where at all possible.
+    """
+
+    def _delete_obsolete_leases(self, nodegroup, current_leases):
+        """Delete leases for `nodegroup` that aren't in `current_leases`."""
+        clauses = ["nodegroup_id = %s" % nodegroup.id]
+        leases_sql = sqlify_ip_mac_pairs(current_leases.items())
+        if len(current_leases) == 0:
+            pass
+        elif len(current_leases) == 1:
+            clauses.append("(ip, mac) <> %s" % leases_sql)
+        else:
+            clauses.append("(ip, mac) NOT IN (%s)" % leases_sql)
+        connection.cursor().execute(
+            "DELETE FROM maasserver_dhcplease WHERE %s"
+            % " AND ".join(clauses)),
+
+    def _get_leased_ips(self, nodegroup):
+        """Query the currently leased IP addresses for `nodegroup`."""
+        cursor = connection.cursor()
+        cursor.execute(
+            "SELECT ip FROM maasserver_dhcplease WHERE nodegroup_id = %s"
+            % nodegroup.id)
+        return frozenset(ip for ip, in cursor.fetchall())
+
+    def _add_missing_leases(self, nodegroup, leases):
+        """Add items from `leases` that aren't in the database yet.
+
+        This is assumed to be run right after _delete_obsolete_leases,
+        so that a lease from `leases` is in the database if and only if
+        `nodegroup` has a DHCPLease with the same `ip` field.  There
+        can't be any DHCPLease entries with the same `ip` as in `leases`
+        but a different `mac`.
+        """
+        leased_ips = self._get_leased_ips(nodegroup)
+        new_pairs = [
+            (ip, mac)
+            for ip, mac in leases.items() if ip not in leased_ips]
+        if len(new_pairs) > 0:
+            connection.cursor().execute("""
+                INSERT INTO maasserver_dhcplease (nodegroup_id, ip, mac)
+                VALUES %s
+                """ % sqlify_nodegroup_ip_mac_pairs(nodegroup, new_pairs))
+
+    def update_leases(self, nodegroup, leases):
+        """Refresh our knowledge of a node group's IP mappings.
+
+        This deletes entries that are no longer current, adds new ones,
+        and updates or replaces ones that have changed.
+
+        :param nodegroup: The node group that these updates are for.
+        :param leases: A dict describing all current IP/MAC mappings as
+            managed by the node group's DHCP server.  Keys are IP
+            addresses, values are MAC addresses.  Any :class:`DHCPLease`
+            entries for `nodegroup` that are not in `leases` will be
+            deleted.
+        """
+        self._delete_obsolete_leases(nodegroup, leases)
+        self._add_missing_leases(nodegroup, leases)
+
+
+class DHCPLease(CleanSave, Model):
+    """A known mapping of an IP address to a MAC address.
+
+    These correspond to the latest-known DHCP leases handed out to nodes
+    (or potential nodes -- they may not have been enlisted yet!) by the
+    node group worker's DHCP server.
+    """
+
+    class Meta(DefaultMeta):
+        """Needed for South to recognize this model."""
+
+    objects = DHCPLeaseManager()
+
+    nodegroup = ForeignKey('maasserver.NodeGroup', null=False, editable=False)
+    ip = IPAddressField(null=False, editable=False, unique=True)
+    mac = MACAddressField(null=False, editable=False, unique=False)
+
+    def __unicode__(self):
+        return "%s->%s" % (self.ip, self.mac)

=== modified file 'src/maasserver/testing/factory.py'
--- src/maasserver/testing/factory.py	2012-07-10 11:42:48 +0000
+++ src/maasserver/testing/factory.py	2012-07-10 17:41:23 +0000
@@ -24,6 +24,7 @@
     NODE_STATUS,
     )
 from maasserver.models import (
+    DHCPLease,
     FileStorage,
     MACAddress,
     Node,
@@ -156,6 +157,18 @@
         mac.save()
         return mac
 
+    def make_dhcp_lease(self, nodegroup=None, ip=None, mac=None):
+        """Create a :class:`DHCPLease`."""
+        if nodegroup is None:
+            nodegroup = self.make_node_group()
+        if ip is None:
+            ip = self.getRandomIPAddress()
+        if mac is None:
+            mac = self.getRandomMACAddress()
+        lease = DHCPLease(nodegroup=nodegroup, ip=ip, mac=mac)
+        lease.save()
+        return lease
+
     def make_user(self, username=None, password=None, email=None):
         if username is None:
             username = self.getRandomString(10)

=== added file 'src/maasserver/tests/test_dhcplease.py'
--- src/maasserver/tests/test_dhcplease.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_dhcplease.py	2012-07-10 17:41:23 +0000
@@ -0,0 +1,136 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the :class:`DHCPLease` model."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from maasserver.models.dhcplease import DHCPLease
+from maasserver.testing.factory import factory
+from maasserver.testing.testcase import TestCase
+from maasserver.utils import ignore_unused
+
+
+def get_leases(nodegroup):
+    """Return DHCPLease records for `nodegroup`."""
+    return DHCPLease.objects.filter(nodegroup=nodegroup)
+
+
+def map_leases(nodegroup):
+    """Return IP/MAC mappings dict for leases in `nodegroup`."""
+    return {lease.ip: lease.mac for lease in get_leases(nodegroup)}
+
+
+class TestDHCPLease(TestCase):
+    """Tests for :class:`DHCPLease`."""
+
+    def test_init(self):
+        nodegroup = factory.make_node_group()
+        ip = factory.getRandomIPAddress()
+        mac = factory.getRandomMACAddress()
+
+        lease = DHCPLease(nodegroup=nodegroup, ip=ip, mac=mac)
+        lease.save()
+
+        self.assertItemsEqual([lease], get_leases(nodegroup))
+        self.assertEqual(nodegroup, lease.nodegroup)
+        self.assertEqual(ip, lease.ip)
+        self.assertEqual(mac, lease.mac)
+
+
+class TestDHCPLeaseManager(TestCase):
+    """Tests for :class:`DHCPLeaseManager`."""
+
+    def test_update_leases_accepts_empty_leases(self):
+        nodegroup = factory.make_node_group()
+        DHCPLease.objects.update_leases(nodegroup, {})
+        self.assertItemsEqual([], get_leases(nodegroup))
+
+    def test_update_leases_creates_new_lease(self):
+        nodegroup = factory.make_node_group()
+        ip = factory.getRandomIPAddress()
+        mac = factory.getRandomMACAddress()
+        DHCPLease.objects.update_leases(nodegroup, {ip: mac})
+        self.assertEqual({ip: mac}, map_leases(nodegroup))
+
+    def test_update_leases_deletes_obsolete_lease(self):
+        nodegroup = factory.make_node_group()
+        factory.make_dhcp_lease(nodegroup=nodegroup)
+        DHCPLease.objects.update_leases(nodegroup, {})
+        self.assertItemsEqual([], get_leases(nodegroup))
+
+    def test_update_leases_replaces_reassigned_ip(self):
+        nodegroup = factory.make_node_group()
+        ip = factory.getRandomIPAddress()
+        factory.make_dhcp_lease(nodegroup=nodegroup, ip=ip)
+        new_mac = factory.getRandomMACAddress()
+        DHCPLease.objects.update_leases(nodegroup, {ip: new_mac})
+        self.assertEqual({ip: new_mac}, map_leases(nodegroup))
+
+    def test_update_leases_keeps_unchanged_mappings(self):
+        original_lease = factory.make_dhcp_lease()
+        nodegroup = original_lease.nodegroup
+        DHCPLease.objects.update_leases(
+            nodegroup, {original_lease.ip: original_lease.mac})
+        self.assertItemsEqual([original_lease], get_leases(nodegroup))
+
+    def test_update_leases_adds_new_ip_to_mac(self):
+        nodegroup = factory.make_node_group()
+        mac = factory.getRandomMACAddress()
+        ip1 = factory.getRandomIPAddress()
+        ip2 = factory.getRandomIPAddress()
+        factory.make_dhcp_lease(nodegroup=nodegroup, mac=mac, ip=ip1)
+        DHCPLease.objects.update_leases(nodegroup, {ip1: mac, ip2: mac})
+        self.assertEqual({ip1: mac, ip2: mac}, map_leases(nodegroup))
+
+    def test_update_leases_deletes_only_obsolete_ips(self):
+        nodegroup = factory.make_node_group()
+        mac = factory.getRandomMACAddress()
+        obsolete_ip = factory.getRandomIPAddress()
+        current_ip = factory.getRandomIPAddress()
+        factory.make_dhcp_lease(nodegroup=nodegroup, mac=mac, ip=obsolete_ip)
+        factory.make_dhcp_lease(nodegroup=nodegroup, mac=mac, ip=current_ip)
+        DHCPLease.objects.update_leases(nodegroup, {current_ip: mac})
+        self.assertEqual({current_ip: mac}, map_leases(nodegroup))
+
+    def test_update_leases_leaves_other_nodegroups_alone(self):
+        innocent_nodegroup = factory.make_node_group()
+        innocent_lease = factory.make_dhcp_lease(nodegroup=innocent_nodegroup)
+        DHCPLease.objects.update_leases(
+            factory.make_node_group(),
+            {factory.getRandomIPAddress(): factory.getRandomMACAddress()})
+        self.assertItemsEqual(
+            [innocent_lease], get_leases(innocent_nodegroup))
+
+    def test_update_leases_combines_additions_deletions_and_replacements(self):
+        nodegroup = factory.make_node_group()
+        mac1 = factory.getRandomMACAddress()
+        mac2 = factory.getRandomMACAddress()
+        obsolete_lease = factory.make_dhcp_lease(
+            nodegroup=nodegroup, mac=mac1)
+        # The obsolete lease won't be in the update, so it'll disappear.
+        ignore_unused(obsolete_lease)
+        unchanged_lease = factory.make_dhcp_lease(
+            nodegroup=nodegroup, mac=mac1)
+        reassigned_lease = factory.make_dhcp_lease(
+            nodegroup=nodegroup, mac=mac1)
+        new_ip = factory.getRandomIPAddress()
+        DHCPLease.objects.update_leases(nodegroup, {
+            reassigned_lease.ip: mac2,
+            unchanged_lease.ip: mac1,
+            new_ip: mac1,
+        })
+        self.assertEqual(
+            {
+                reassigned_lease.ip: mac2,
+                unchanged_lease.ip: mac1,
+                new_ip: mac1,
+            },
+            map_leases(nodegroup))