[ONOS-6371] (vNet) simple mastership store

Implement simple mastership store that trivial and in-memory
inventory for mastership.

Change-Id: I713d1379591cfdddf7803d68817040096bda8bdf
diff --git a/incubator/net/src/main/java/org/onosproject/incubator/net/virtual/impl/VirtualNetworkMastershipManager.java b/incubator/net/src/main/java/org/onosproject/incubator/net/virtual/impl/VirtualNetworkMastershipManager.java
index b651fb1..7d973e0 100644
--- a/incubator/net/src/main/java/org/onosproject/incubator/net/virtual/impl/VirtualNetworkMastershipManager.java
+++ b/incubator/net/src/main/java/org/onosproject/incubator/net/virtual/impl/VirtualNetworkMastershipManager.java
@@ -199,5 +199,4 @@
             post(event);
         }
     }
-
 }
diff --git a/incubator/store/src/main/java/org/onosproject/incubator/store/virtual/impl/SimpleVirtualMastershipStore.java b/incubator/store/src/main/java/org/onosproject/incubator/store/virtual/impl/SimpleVirtualMastershipStore.java
index bd972b2..47b9c6f 100644
--- a/incubator/store/src/main/java/org/onosproject/incubator/store/virtual/impl/SimpleVirtualMastershipStore.java
+++ b/incubator/store/src/main/java/org/onosproject/incubator/store/virtual/impl/SimpleVirtualMastershipStore.java
@@ -16,10 +16,20 @@
 
 package org.onosproject.incubator.store.virtual.impl;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import org.apache.felix.scr.annotations.Activate;
 import org.apache.felix.scr.annotations.Component;
 import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.ReferenceCardinality;
 import org.apache.felix.scr.annotations.Service;
+import org.joda.time.DateTime;
+import org.onlab.packet.IpAddress;
+import org.onosproject.cluster.ClusterEventListener;
+import org.onosproject.cluster.ClusterService;
+import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.DefaultControllerNode;
 import org.onosproject.cluster.NodeId;
 import org.onosproject.cluster.RoleInfo;
 import org.onosproject.incubator.net.virtual.NetworkId;
@@ -31,9 +41,19 @@
 import org.onosproject.net.MastershipRole;
 import org.slf4j.Logger;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.onosproject.mastership.MastershipEvent.Type.BACKUPS_CHANGED;
+import static org.onosproject.mastership.MastershipEvent.Type.MASTER_CHANGED;
 import static org.slf4j.LoggerFactory.getLogger;
 
 /**
@@ -48,8 +68,27 @@
 
     private final Logger log = getLogger(getClass());
 
+    private static final int NOTHING = 0;
+    private static final int INIT = 1;
+
+    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
+    protected ClusterService clusterService;
+
+    //devices mapped to their masters, to emulate multiple nodes
+    protected final Map<NetworkId, Map<DeviceId, NodeId>> masterMapByNetwork =
+            new HashMap<>();
+    //emulate backups with pile of nodes
+    protected final Map<NetworkId, Map<DeviceId, List<NodeId>>> backupsByNetwork =
+            new HashMap<>();
+    //terms
+    protected final Map<NetworkId, Map<DeviceId, AtomicInteger>> termMapByNetwork =
+            new HashMap<>();
+
     @Activate
     public void activate() {
+        if (clusterService == null) {
+            clusterService = createFakeClusterService();
+        }
         log.info("Started");
     }
 
@@ -59,55 +98,399 @@
     }
 
     @Override
-    public CompletableFuture<MastershipRole> requestRole(NetworkId networkId, DeviceId deviceId) {
-        return null;
+    public CompletableFuture<MastershipRole> requestRole(NetworkId networkId,
+                                                         DeviceId deviceId) {
+        //query+possible reelection
+        NodeId node = clusterService.getLocalNode().id();
+        MastershipRole role = getRole(networkId, node, deviceId);
+
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+
+        switch (role) {
+            case MASTER:
+                return CompletableFuture.completedFuture(MastershipRole.MASTER);
+            case STANDBY:
+                if (getMaster(networkId, deviceId) == null) {
+                    // no master => become master
+                    masterMap.put(deviceId, node);
+                    incrementTerm(networkId, deviceId);
+                    // remove from backup list
+                    removeFromBackups(networkId, deviceId, node);
+                    notifyDelegate(networkId, new MastershipEvent(MASTER_CHANGED, deviceId,
+                                                       getNodes(networkId, deviceId)));
+                    return CompletableFuture.completedFuture(MastershipRole.MASTER);
+                }
+                return CompletableFuture.completedFuture(MastershipRole.STANDBY);
+            case NONE:
+                if (getMaster(networkId, deviceId) == null) {
+                    // no master => become master
+                    masterMap.put(deviceId, node);
+                    incrementTerm(networkId, deviceId);
+                    notifyDelegate(networkId, new MastershipEvent(MASTER_CHANGED, deviceId,
+                                                       getNodes(networkId, deviceId)));
+                    return CompletableFuture.completedFuture(MastershipRole.MASTER);
+                }
+                // add to backup list
+                if (addToBackup(networkId, deviceId, node)) {
+                    notifyDelegate(networkId, new MastershipEvent(BACKUPS_CHANGED, deviceId,
+                                                       getNodes(networkId, deviceId)));
+                }
+                return CompletableFuture.completedFuture(MastershipRole.STANDBY);
+            default:
+                log.warn("unknown Mastership Role {}", role);
+        }
+        return CompletableFuture.completedFuture(role);
     }
 
     @Override
     public MastershipRole getRole(NetworkId networkId, NodeId nodeId, DeviceId deviceId) {
-        return null;
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+        Map<DeviceId, List<NodeId>> backups = getBackups(networkId);
+
+        //just query
+        NodeId current = masterMap.get(deviceId);
+        MastershipRole role;
+
+        if (current != null && current.equals(nodeId)) {
+            return MastershipRole.MASTER;
+        }
+
+        if (backups.getOrDefault(deviceId, Collections.emptyList()).contains(nodeId)) {
+            role = MastershipRole.STANDBY;
+        } else {
+            role = MastershipRole.NONE;
+        }
+        return role;
     }
 
     @Override
     public NodeId getMaster(NetworkId networkId, DeviceId deviceId) {
-        return null;
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+        return masterMap.get(deviceId);
     }
 
     @Override
     public RoleInfo getNodes(NetworkId networkId, DeviceId deviceId) {
-        return null;
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+        Map<DeviceId, List<NodeId>> backups = getBackups(networkId);
+
+        return new RoleInfo(masterMap.get(deviceId),
+                            backups.getOrDefault(deviceId, ImmutableList.of()));
     }
 
     @Override
     public Set<DeviceId> getDevices(NetworkId networkId, NodeId nodeId) {
-        return null;
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+
+        Set<DeviceId> ids = new HashSet<>();
+        for (Map.Entry<DeviceId, NodeId> d : masterMap.entrySet()) {
+            if (Objects.equals(d.getValue(), nodeId)) {
+                ids.add(d.getKey());
+            }
+        }
+        return ids;
     }
 
     @Override
-    public CompletableFuture<MastershipEvent> setMaster(NetworkId networkId,
+    public synchronized CompletableFuture<MastershipEvent> setMaster(NetworkId networkId,
                                                         NodeId nodeId, DeviceId deviceId) {
-        return null;
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+
+        MastershipRole role = getRole(networkId, nodeId, deviceId);
+        switch (role) {
+            case MASTER:
+                // no-op
+                return CompletableFuture.completedFuture(null);
+            case STANDBY:
+            case NONE:
+                NodeId prevMaster = masterMap.put(deviceId, nodeId);
+                incrementTerm(networkId, deviceId);
+                removeFromBackups(networkId, deviceId, nodeId);
+                addToBackup(networkId, deviceId, prevMaster);
+                break;
+            default:
+                log.warn("unknown Mastership Role {}", role);
+                return null;
+        }
+
+        return CompletableFuture.completedFuture(
+                new MastershipEvent(MASTER_CHANGED, deviceId, getNodes(networkId, deviceId)));
     }
 
     @Override
     public MastershipTerm getTermFor(NetworkId networkId, DeviceId deviceId) {
-        return null;
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+        Map<DeviceId, AtomicInteger> termMap = getTermMap(networkId);
+
+        if ((termMap.get(deviceId) == null)) {
+            return MastershipTerm.of(masterMap.get(deviceId), NOTHING);
+        }
+        return MastershipTerm.of(
+                masterMap.get(deviceId), termMap.get(deviceId).get());
     }
 
     @Override
     public CompletableFuture<MastershipEvent> setStandby(NetworkId networkId,
                                                          NodeId nodeId, DeviceId deviceId) {
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+
+        MastershipRole role = getRole(networkId, nodeId, deviceId);
+        switch (role) {
+            case MASTER:
+                NodeId backup = reelect(networkId, deviceId, nodeId);
+                if (backup == null) {
+                    // no master alternative
+                    masterMap.remove(deviceId);
+                    // TODO: Should there be new event type for no MASTER?
+                    return CompletableFuture.completedFuture(
+                            new MastershipEvent(MASTER_CHANGED, deviceId,
+                                                getNodes(networkId, deviceId)));
+                } else {
+                    NodeId prevMaster = masterMap.put(deviceId, backup);
+                    incrementTerm(networkId, deviceId);
+                    addToBackup(networkId, deviceId, prevMaster);
+                    return CompletableFuture.completedFuture(
+                            new MastershipEvent(MASTER_CHANGED, deviceId,
+                                                getNodes(networkId, deviceId)));
+                }
+
+            case STANDBY:
+            case NONE:
+                boolean modified = addToBackup(networkId, deviceId, nodeId);
+                if (modified) {
+                    return CompletableFuture.completedFuture(
+                            new MastershipEvent(BACKUPS_CHANGED, deviceId,
+                                                getNodes(networkId, deviceId)));
+                }
+                break;
+
+            default:
+                log.warn("unknown Mastership Role {}", role);
+        }
         return null;
     }
 
+
+    /**
+     * Dumbly selects next-available node that's not the current one.
+     * emulate leader election.
+     *
+     * @param networkId a virtual network identifier
+     * @param deviceId a virtual device identifier
+     * @param nodeId a nod identifier
+     * @return Next available node as a leader
+     */
+    private synchronized NodeId reelect(NetworkId networkId, DeviceId deviceId,
+                                        NodeId nodeId) {
+        Map<DeviceId, List<NodeId>> backups = getBackups(networkId);
+
+        List<NodeId> stbys = backups.getOrDefault(deviceId, Collections.emptyList());
+        NodeId backup = null;
+        for (NodeId n : stbys) {
+            if (!n.equals(nodeId)) {
+                backup = n;
+                break;
+            }
+        }
+        stbys.remove(backup);
+        return backup;
+    }
+
     @Override
-    public CompletableFuture<MastershipEvent> relinquishRole(NetworkId networkId,
-                                                             NodeId nodeId, DeviceId deviceId) {
-        return null;
+    public synchronized CompletableFuture<MastershipEvent>
+    relinquishRole(NetworkId networkId, NodeId nodeId, DeviceId deviceId) {
+    Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+
+        MastershipRole role = getRole(networkId, nodeId, deviceId);
+        switch (role) {
+            case MASTER:
+                NodeId backup = reelect(networkId, deviceId, nodeId);
+                masterMap.put(deviceId, backup);
+                incrementTerm(networkId, deviceId);
+                return CompletableFuture.completedFuture(
+                        new MastershipEvent(MASTER_CHANGED, deviceId,
+                                            getNodes(networkId, deviceId)));
+
+            case STANDBY:
+                if (removeFromBackups(networkId, deviceId, nodeId)) {
+                    return CompletableFuture.completedFuture(
+                            new MastershipEvent(BACKUPS_CHANGED, deviceId,
+                                                getNodes(networkId, deviceId)));
+                }
+                break;
+
+            case NONE:
+                break;
+
+            default:
+                log.warn("unknown Mastership Role {}", role);
+        }
+        return CompletableFuture.completedFuture(null);
     }
 
     @Override
     public void relinquishAllRole(NetworkId networkId, NodeId nodeId) {
+        Map<DeviceId, NodeId> masterMap = getMasterMap(networkId);
+        Map<DeviceId, List<NodeId>> backups = getBackups(networkId);
 
+        List<CompletableFuture<MastershipEvent>> eventFutures = new ArrayList<>();
+        Set<DeviceId> toRelinquish = new HashSet<>();
+
+        masterMap.entrySet().stream()
+                .filter(entry -> nodeId.equals(entry.getValue()))
+                .forEach(entry -> toRelinquish.add(entry.getKey()));
+
+        backups.entrySet().stream()
+                .filter(entry -> entry.getValue().contains(nodeId))
+                .forEach(entry -> toRelinquish.add(entry.getKey()));
+
+        toRelinquish.forEach(deviceId -> eventFutures.add(
+                relinquishRole(networkId, nodeId, deviceId)));
+
+        eventFutures.forEach(future -> {
+            future.whenComplete((event, error) -> notifyDelegate(networkId, event));
+        });
+    }
+
+    /**
+     * Increase the term for a device, and store it.
+     *
+     * @param networkId a virtual network identifier
+     * @param deviceId a virtual device identifier
+     */
+    private synchronized void incrementTerm(NetworkId networkId, DeviceId deviceId) {
+        Map<DeviceId, AtomicInteger> termMap = getTermMap(networkId);
+
+        AtomicInteger term = termMap.getOrDefault(deviceId, new AtomicInteger(NOTHING));
+        term.incrementAndGet();
+        termMap.put(deviceId, term);
+    }
+
+    /**
+     * Remove backup node for a device.
+     *
+     * @param networkId a virtual network identifier
+     * @param deviceId a virtual device identifier
+     * @param nodeId a node identifier
+     * @return True if success
+     */
+    private synchronized boolean removeFromBackups(NetworkId networkId,
+                                                   DeviceId deviceId, NodeId nodeId) {
+        Map<DeviceId, List<NodeId>> backups = getBackups(networkId);
+
+        List<NodeId> stbys = backups.getOrDefault(deviceId, new ArrayList<>());
+        boolean modified = stbys.remove(nodeId);
+        backups.put(deviceId, stbys);
+        return modified;
+    }
+
+    /**
+     * add to backup if not there already, silently ignores null node.
+     *
+     * @param networkId a virtual network identifier
+     * @param deviceId a virtual device identifier
+     * @param nodeId a node identifier
+     * @return True if success
+     */
+    private synchronized boolean addToBackup(NetworkId networkId,
+                                             DeviceId deviceId, NodeId nodeId) {
+        Map<DeviceId, List<NodeId>> backups = getBackups(networkId);
+
+        boolean modified = false;
+        List<NodeId> stbys = backups.getOrDefault(deviceId, new ArrayList<>());
+        if (nodeId != null && !stbys.contains(nodeId)) {
+            stbys.add(nodeId);
+            backups.put(deviceId, stbys);
+            modified = true;
+        }
+        return modified;
+    }
+
+    /**
+     * Returns deviceId-master map for a specified virtual network.
+     *
+     * @param networkId a virtual network identifier
+     * @return DeviceId-master map of a given virtual network.
+     */
+    private Map<DeviceId, NodeId> getMasterMap(NetworkId networkId) {
+        return masterMapByNetwork.computeIfAbsent(networkId, k -> new HashMap<>());
+    }
+
+    /**
+     * Returns deviceId-backups map for a specified virtual network.
+     *
+     * @param networkId a virtual network identifier
+     * @return DeviceId-backups map of a given virtual network.
+     */
+    private Map<DeviceId, List<NodeId>> getBackups(NetworkId networkId) {
+        return backupsByNetwork.computeIfAbsent(networkId, k -> new HashMap<>());
+    }
+
+    /**
+     * Returns deviceId-terms map for a specified virtual network.
+     *
+     * @param networkId a virtual network identifier
+     * @return DeviceId-terms map of a given virtual network.
+     */
+    private Map<DeviceId, AtomicInteger> getTermMap(NetworkId networkId) {
+        return termMapByNetwork.computeIfAbsent(networkId, k -> new HashMap<>());
+    }
+
+    /**
+     * Returns a fake cluster service for a test purpose only.
+     *
+     * @return a fake cluster service
+     */
+    private ClusterService createFakeClusterService() {
+        // just for ease of unit test
+        final ControllerNode instance =
+                new DefaultControllerNode(new NodeId("local"),
+                                          IpAddress.valueOf("127.0.0.1"));
+
+        ClusterService faceClusterService = new ClusterService() {
+
+            private final DateTime creationTime = DateTime.now();
+
+            @Override
+            public ControllerNode getLocalNode() {
+                return instance;
+            }
+
+            @Override
+            public Set<ControllerNode> getNodes() {
+                return ImmutableSet.of(instance);
+            }
+
+            @Override
+            public ControllerNode getNode(NodeId nodeId) {
+                if (instance.id().equals(nodeId)) {
+                    return instance;
+                }
+                return null;
+            }
+
+            @Override
+            public ControllerNode.State getState(NodeId nodeId) {
+                if (instance.id().equals(nodeId)) {
+                    return ControllerNode.State.ACTIVE;
+                } else {
+                    return ControllerNode.State.INACTIVE;
+                }
+            }
+
+            @Override
+            public DateTime getLastUpdated(NodeId nodeId) {
+                return creationTime;
+            }
+
+            @Override
+            public void addListener(ClusterEventListener listener) {
+            }
+
+            @Override
+            public void removeListener(ClusterEventListener listener) {
+            }
+        };
+        return faceClusterService;
     }
 }
diff --git a/incubator/store/src/test/java/org/onosproject/incubator/store/virtual/impl/SimpleVirtualMastershipStoreTest.java b/incubator/store/src/test/java/org/onosproject/incubator/store/virtual/impl/SimpleVirtualMastershipStoreTest.java
new file mode 100644
index 0000000..1ad76f2
--- /dev/null
+++ b/incubator/store/src/test/java/org/onosproject/incubator/store/virtual/impl/SimpleVirtualMastershipStoreTest.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.onosproject.incubator.store.virtual.impl;
+
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.Futures;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.onosproject.cluster.NodeId;
+import org.onosproject.incubator.net.virtual.NetworkId;
+import org.onosproject.mastership.MastershipEvent;
+import org.onosproject.mastership.MastershipTerm;
+import org.onosproject.net.DeviceId;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.onosproject.mastership.MastershipEvent.Type.MASTER_CHANGED;
+import static org.onosproject.net.MastershipRole.MASTER;
+import static org.onosproject.net.MastershipRole.NONE;
+import static org.onosproject.net.MastershipRole.STANDBY;
+
+public class SimpleVirtualMastershipStoreTest {
+
+    private static NetworkId VNID1 = NetworkId.networkId(1);
+
+    private static final DeviceId VDID1 = DeviceId.deviceId("of:01");
+    private static final DeviceId VDID2 = DeviceId.deviceId("of:02");
+    private static final DeviceId VDID3 = DeviceId.deviceId("of:03");
+    private static final DeviceId VDID4 = DeviceId.deviceId("of:04");
+
+    private static final NodeId N1 = new NodeId("local");
+    private static final NodeId N2 = new NodeId("other");
+
+    private SimpleVirtualMastershipStore sms;
+
+    @Before
+    public void setUp() throws Exception {
+        sms = new SimpleVirtualMastershipStore();
+        sms.activate();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        sms.deactivate();
+    }
+
+    @Test
+    public void getRole() {
+        //special case, no backup or master
+        put(VNID1, VDID1, N1, false, false);
+        assertEquals("wrong role", NONE, sms.getRole(VNID1, N1, VDID1));
+
+        //backup exists but we aren't mapped
+        put(VNID1, VDID2, N1, false, true);
+        assertEquals("wrong role", STANDBY, sms.getRole(VNID1, N1, VDID2));
+
+        //N2 is master
+        put(VNID1, VDID3, N2, true, true);
+        assertEquals("wrong role", MASTER, sms.getRole(VNID1, N2, VDID3));
+
+        //N2 is master but N1 is only in backups set
+        put(VNID1, VDID4, N1, false, true);
+        put(VNID1, VDID4, N2, true, false);
+        assertEquals("wrong role", STANDBY, sms.getRole(VNID1, N1, VDID4));
+    }
+
+    @Test
+    public void getMaster() {
+        put(VNID1, VDID3, N2, true, true);
+        assertEquals("wrong role", MASTER, sms.getRole(VNID1, N2, VDID3));
+        assertEquals("wrong node", N2, sms.getMaster(VNID1, VDID3));
+    }
+
+    @Test
+    public void setMaster() {
+        put(VNID1, VDID1, N1, false, false);
+        assertEquals("wrong event", MASTER_CHANGED,
+                     Futures.getUnchecked(sms.setMaster(VNID1, N1, VDID1)).type());
+        assertEquals("wrong role", MASTER, sms.getRole(VNID1, N1, VDID1));
+        //set node that's already master - should be ignored
+        assertNull("wrong event",
+                   Futures.getUnchecked(sms.setMaster(VNID1, N1, VDID1)));
+
+        //set STANDBY to MASTER
+        put(VNID1, VDID2, N1, false, true);
+        assertEquals("wrong role", STANDBY, sms.getRole(VNID1, N1, VDID2));
+        assertEquals("wrong event", MASTER_CHANGED,
+                     Futures.getUnchecked(sms.setMaster(VNID1, N1, VDID2)).type());
+        assertEquals("wrong role", MASTER, sms.getRole(VNID1, N1, VDID2));
+    }
+
+    @Test
+    public void getDevices() {
+        Set<DeviceId> d = Sets.newHashSet(VDID1, VDID2);
+
+        put(VNID1, VDID1, N2, true, true);
+        put(VNID1, VDID2, N2, true, true);
+        put(VNID1, VDID3, N1, true, true);
+        assertTrue("wrong devices", d.equals(sms.getDevices(VNID1, N2)));
+    }
+
+    @Test
+    public void getTermFor() {
+        put(VNID1, VDID1, N1, true, true);
+        assertEquals("wrong term", MastershipTerm.of(N1, 0),
+                     sms.getTermFor(VNID1, VDID1));
+
+        //switch to N2 and back - 2 term switches
+        sms.setMaster(VNID1, N2, VDID1);
+        sms.setMaster(VNID1, N1, VDID1);
+        assertEquals("wrong term", MastershipTerm.of(N1, 2),
+                     sms.getTermFor(VNID1, VDID1));
+    }
+
+    @Test
+    public void requestRole() {
+        //NONE - become MASTER
+        put(VNID1, VDID1, N1, false, false);
+        assertEquals("wrong role", MASTER,
+                     Futures.getUnchecked(sms.requestRole(VNID1, VDID1)));
+
+        //was STANDBY - become MASTER
+        put(VNID1, VDID2, N1, false, true);
+        assertEquals("wrong role", MASTER,
+                     Futures.getUnchecked(sms.requestRole(VNID1, VDID2)));
+
+        //other MASTER - stay STANDBY
+        put(VNID1, VDID3, N2, true, false);
+        assertEquals("wrong role", STANDBY,
+                     Futures.getUnchecked(sms.requestRole(VNID1, VDID3)));
+
+        //local (N1) is MASTER - stay MASTER
+        put(VNID1, VDID4, N1, true, true);
+        assertEquals("wrong role", MASTER,
+                     Futures.getUnchecked(sms.requestRole(VNID1, VDID4)));
+    }
+
+    @Test
+    public void unsetMaster() {
+        //NONE - record backup but take no other action
+        put(VNID1, VDID1, N1, false, false);
+        sms.setStandby(VNID1, N1, VDID1);
+        assertTrue("not backed up", sms.backupsByNetwork.get(VNID1)
+                .get(VDID1).contains(N1));
+        int prev = sms.termMapByNetwork.get(VNID1).get(VDID1).get();
+        sms.setStandby(VNID1, N1, VDID1);
+        assertEquals("term should not change", prev, sms.termMapByNetwork.get(VNID1)
+                .get(VDID1).get());
+
+        //no backup, MASTER
+        put(VNID1, VDID1, N1, true, false);
+        assertNull("expect no MASTER event",
+                   Futures.getUnchecked(sms.setStandby(VNID1, N1, VDID1)).roleInfo().master());
+        assertNull("wrong node", sms.masterMapByNetwork.get(VNID1).get(VDID1));
+
+        //backup, switch
+        sms.masterMapByNetwork.get(VNID1).clear();
+        put(VNID1, VDID1, N1, true, true);
+        put(VNID1, VDID1, N2, false, true);
+        put(VNID1, VDID2, N2, true, true);
+        MastershipEvent event = Futures.getUnchecked(sms.setStandby(VNID1, N1, VDID1));
+        assertEquals("wrong event", MASTER_CHANGED, event.type());
+        assertEquals("wrong master", N2, event.roleInfo().master());
+    }
+
+    //helper to populate master/backup structures
+    private void put(NetworkId networkId, DeviceId dev, NodeId node,
+                     boolean master, boolean backup) {
+        if (master) {
+            sms.masterMapByNetwork
+                    .computeIfAbsent(networkId, k -> new HashMap<>())
+                    .put(dev, node);
+        } else if (backup) {
+            List<NodeId> stbys = sms.backupsByNetwork
+                    .computeIfAbsent(networkId, k -> new HashMap<>())
+                    .getOrDefault(dev, new ArrayList<>());
+            stbys.add(node);
+            sms.backupsByNetwork.get(networkId).put(dev, stbys);
+        }
+
+        sms.termMapByNetwork
+                .computeIfAbsent(networkId, k -> new HashMap<>())
+                .put(dev, new AtomicInteger());
+    }
+}
\ No newline at end of file