autotest: add a remove_boards_from_shard RPC

BUG=chromium:704445
TEST=unittests added, and they pass

Change-Id: I1dbbc68cbb29a341e9d35d1bdb322f1bb398da74
Reviewed-on: https://chromium-review.googlesource.com/467990
Commit-Ready: Aviv Keshet <akeshet@chromium.org>
Tested-by: Aviv Keshet <akeshet@chromium.org>
Reviewed-by: Dan Shi <dshi@google.com>
diff --git a/frontend/afe/rpc_interface.py b/frontend/afe/rpc_interface.py
index e17a72c..2ea3d65 100644
--- a/frontend/afe/rpc_interface.py
+++ b/frontend/afe/rpc_interface.py
@@ -37,6 +37,7 @@
 import os
 import sys
 
+from django.db import transaction
 from django.db.models import Count
 
 import common
@@ -2063,6 +2064,30 @@
     return shard.id
 
 
+# Remove board RPCs are rare, so we can afford to make them a bit more
+# expensive (by performing in a transaction) in order to guarantee
+# atomicity.
+# TODO(akeshet): If we ever update to newer version of django, we need to
+# migrate to transaction.atomic instead of commit_on_success
+@transaction.commit_on_success
+def remove_board_from_shard(hostname, label):
+    """Remove board from the given shard.
+    @param hostname: The hostname of the shard to be changed.
+    @param labels: Board label.
+
+    @raises models.Label.DoesNotExist: If the label specified doesn't exist.
+
+    @returns: The id of the changed shard.
+    """
+    shard = models.Shard.objects.get(hostname=hostname)
+    label = models.Label.smart_get(label)
+    if label not in shard.labels.all():
+        raise error.RPCException(
+          'Cannot remove label from shard that does not belong to it.')
+    shard.labels.remove(label)
+    models.Host.objects.filter(labels__in=[label]).update(shard=None)
+
+
 def delete_shard(hostname):
     """Delete a shard and reclaim all resources from it.
 
diff --git a/frontend/afe/rpc_interface_unittest.py b/frontend/afe/rpc_interface_unittest.py
index c62f42a..7b9fc29 100755
--- a/frontend/afe/rpc_interface_unittest.py
+++ b/frontend/afe/rpc_interface_unittest.py
@@ -1116,13 +1116,8 @@
         # shard1.
         self.mox.StubOutWithMock(models.Host, '_assign_to_shard_nothing_helper')
         def remove_label():
-            # A separate RPC call to remove_board_from_shard (not yet
-            # implemented) swoops in and removes the label from the shard and
-            # the shard from all previously assigned hosts.
-            shard1.labels.remove(lumpy_label)
-            models.Host.objects.filter(
-                labels__in=[lumpy_label]
-            ).update(shard=None)
+            rpc_interface.remove_board_from_shard(
+                    shard1.hostname, lumpy_label.name)
         models.Host._assign_to_shard_nothing_helper().WithSideEffects(
             remove_label)
         self.mox.ReplayAll()
@@ -1133,6 +1128,30 @@
         self.assertEqual(host2.shard, None)
 
 
+    def testShardLabelRemovalInvalid(self):
+        """Ensure you cannot remove the wrong label from shard."""
+        shard1, host1, lumpy_label = self._createShardAndHostWithLabel()
+        stumpy_label = models.Label.objects.create(
+                name='board:stumpy', platform=True)
+        with self.assertRaises(error.RPCException):
+            rpc_interface.remove_board_from_shard(
+                    shard1.hostname, stumpy_label.name)
+
+
+    def testShardHeartbeatLabelRemoval(self):
+        """Ensure label removal from shard works."""
+        shard1, host1, lumpy_label = self._createShardAndHostWithLabel()
+
+        self.assertEqual(host1.shard, shard1)
+        self.assertItemsEqual(shard1.labels.all(), [lumpy_label])
+        rpc_interface.remove_board_from_shard(
+                shard1.hostname, lumpy_label.name)
+        host1 = models.Host.smart_get(host1.id)
+        shard1 = models.Shard.smart_get(shard1.id)
+        self.assertEqual(host1.shard, None)
+        self.assertItemsEqual(shard1.labels.all(), [])
+
+
     def testShardRetrieveJobs(self):
         """Create jobs and retrieve them."""
         # should never be returned by heartbeat