Wait for correct IP subnet changes during roaming

This allows roaming tests to pass when run against drivers
that do roaming without involving userspace.  In these situations
we're briefly associated with the roaming target before we detect
that our DHCP lease is invalid and re-negotiate the lease.  Tolerate
that window by also waiting for the IP address on the WiFi interface
to move to the correct subnet.

Add a library to make common netblock->mask/subnet/broadcast address
calculations.  Pull duplicate logic to do this out of interface.py
and site_linux_router.py.

BUG=chromium:352847
TEST=wifi_matfunc continues to pass with this change.

Change-Id: I2e6786514aee533e36eb84e1329f19891d3744ba
Reviewed-on: https://chromium-review.googlesource.com/190173
Tested-by: Christopher Wiley <wiley@chromium.org>
Reviewed-by: Paul Stewart <pstew@chromium.org>
Commit-Queue: Christopher Wiley <wiley@chromium.org>
diff --git a/client/common_lib/cros/network/interface.py b/client/common_lib/cros/network/interface.py
index b92ff45..e5e6f92 100644
--- a/client/common_lib/cros/network/interface.py
+++ b/client/common_lib/cros/network/interface.py
@@ -4,11 +4,10 @@
 
 import logging
 import re
-import socket
-import struct
 
 from autotest_lib.client.bin import utils
 from autotest_lib.client.common_lib import error
+from autotest_lib.client.common_lib.cros.network import netblock
 
 class Interface:
     """Interace is a class that contains the queriable address properties
@@ -118,33 +117,29 @@
     @property
     def ipv4_address(self):
         """@return the (first) IPv4 address, e.g., "192.168.0.1"."""
-        address = self.ipv4_address_and_prefix
-        return address if not address else address.split('/')[0]
+        netblock_addr = self.netblock
+        return netblock_addr.addr if netblock_addr else None
 
 
     @property
     def ipv4_prefix(self):
         """@return the IPv4 address prefix e.g., 24."""
-        address = self.ipv4_address_and_prefix
-        if not address or '/' not in address:
-            return None
-        return int(address.split('/')[1])
-        return address if not address else address.split('/')[0]
+        addr = self.netblock
+        return addr.prefix if addr else None
+
+
+    @property
+    def ipv4_subnet(self):
+        """@return string subnet of IPv4 address (e.g. '192.168.0.0')"""
+        addr = self.netblock
+        return addr.subnet if addr else None
 
 
     @property
     def ipv4_subnet_mask(self):
         """@return the IPv4 subnet mask e.g., "255.255.255.0"."""
-        prefix_size = self.ipv4_prefix
-        if not prefix_size:
-            return None
-        if prefix_size <= 0 or prefix_size >= 32:
-            logging.error("Very oddly configured IP address with a /%d "
-                          "prefix size", prefix_size)
-            return None
-        all_ones = 0xffffffff
-        int_mask = ((1 << 32 - prefix_size) - 1) ^ all_ones
-        return socket.inet_ntoa(struct.pack("!I", int_mask))
+        addr = self.netblock
+        return addr.netmask if addr else None
 
 
     def is_wifi_device(self):
@@ -158,6 +153,17 @@
 
 
     @property
+    def netblock(self):
+        """Return Netblock object for this interface's IPv4 address.
+
+        @return Netblock object (or None if no IPv4 address found).
+
+        """
+        netblock_str = self.ipv4_address_and_prefix
+        return netblock.Netblock(netblock_str) if netblock_str else None
+
+
+    @property
     def signal_level(self):
         """Get the signal level for an interface.
 
diff --git a/client/common_lib/cros/network/netblock.py b/client/common_lib/cros/network/netblock.py
new file mode 100644
index 0000000..37aa048
--- /dev/null
+++ b/client/common_lib/cros/network/netblock.py
@@ -0,0 +1,104 @@
+# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+class Netblock(object):
+    """Utility class for transforming netblock address to related strings."""
+
+    @staticmethod
+    def _octets_to_addr(octets):
+        """Transform a list of bytes into a string IP address.
+
+        @param octets list of ints (e.g. [192.168.0.1]).
+        @return string IP address (e.g. '192.168.0.1.').
+
+        """
+        return '.'.join(map(str, octets))
+
+
+    @staticmethod
+    def _int_to_octets(num):
+        """Tranform a 32 bit number into a list of 4 octets.
+
+        @param num: number to convert to octets.
+        @return list of int values <= 8 bits long.
+
+        """
+        return [(num >> s) & 0xff for s in (24, 16, 8, 0)]
+
+
+    @staticmethod
+    def from_addr(addr, prefix_len=32):
+        """Construct a netblock address from a normal IP address.
+
+        @param addr: string IP address (e.g. '192.168.0.1').
+        @param prefix_len int length of IP address prefix.
+        @return Netblock object.
+
+        """
+        return Netblock('/'.join([addr, str(prefix_len)]))
+
+
+    @property
+    def netblock(self):
+        """@return the IPv4 address/prefix, e.g., '192.168.0.1/24'."""
+        return '/'.join([self._octets_to_addr(self._octets),
+                         str(self.prefix_len)])
+
+
+    @property
+    def netmask(self):
+        """@return the IPv4 netmask, e.g., '255.255.255.0'."""
+        return self._octets_to_addr(self._mask_octets)
+
+
+    @property
+    def prefix_len(self):
+        """@return the IPv4 prefix len, e.g., 24."""
+        return self._prefix_len
+
+
+    @property
+    def subnet(self):
+        """@return the IPv4 subnet, e.g., '192.168.0.0'."""
+        octets = [a & m for a, m in zip(self._octets, self._mask_octets)]
+        return self._octets_to_addr(octets)
+
+
+    @property
+    def broadcast(self):
+        """@return the IPv4 broadcast address, e.g., '192.168.0.255'."""
+        octets = [a | (m ^ 0xff)
+                  for a, m in zip(self._octets, self._mask_octets)]
+        return self._octets_to_addr(octets)
+
+
+    @property
+    def addr(self):
+        """@return the IPv4 address, e.g., '192.168.0.1'."""
+        return self._octets_to_addr(self._octets)
+
+
+    def __init__(self, netblock_str):
+        addr_str, bits_str = netblock_str.split('/')
+        self._octets = map(int, addr_str.split('.'))
+        bits = int(bits_str)
+        mask_bits = (-1 << (32 - bits)) & 0xffffffff
+        self._mask_octets = self._int_to_octets(mask_bits)
+        self._prefix_len = bits
+
+
+    def get_addr_in_block(self, offset):
+        """Get an address in a subnet.
+
+        For instance if this netblock represents 192.168.0.1/24,
+        then get_addr_in_block(5) would return 192.168.0.5.
+
+        @param offset int offset in block, (e.g. 5).
+        @return string address (e.g. '192.168.0.5').
+
+        """
+        offset = self._int_to_octets(offset)
+        octets = [(a & m) + o
+                  for a, m, o in zip(self._octets, self._mask_octets, offset)]
+        return self._octets_to_addr(octets)
diff --git a/server/cros/network/wifi_client.py b/server/cros/network/wifi_client.py
index 95b205f..376082f 100644
--- a/server/cros/network/wifi_client.py
+++ b/server/cros/network/wifi_client.py
@@ -148,6 +148,12 @@
 
 
     @property
+    def wifi_ip_subnet(self):
+        """@return string IPv4 subnet prefix of self.wifi_if."""
+        return self._interface.ipv4_subnet
+
+
+    @property
     def wifi_signal_level(self):
         """Returns the signal level of this DUT's WiFi interface.
 
diff --git a/server/cros/network/wifi_test_context_manager.py b/server/cros/network/wifi_test_context_manager.py
index e3237cd..f7bfc1b 100644
--- a/server/cros/network/wifi_test_context_manager.py
+++ b/server/cros/network/wifi_test_context_manager.py
@@ -285,7 +285,7 @@
         if ap_num is None:
             ap_num = 0
         if ping_config is None:
-            ping_ip = self.get_wifi_addr(ap_num=ap_num)
+            ping_ip = self.router.get_wifi_ip(ap_num=ap_num)
             ping_config = ping_runner.PingConfig(ping_ip)
         self.client.ping(ping_config)
 
@@ -321,6 +321,9 @@
         start_time = time.time()
         duration = lambda: time.time() - start_time
         success = False
+        if ap_num is None:
+            ap_num = 0
+        desired_subnet = self.router.get_wifi_ip_subnet(ap_num)
         while duration() < timeout_seconds:
             success, state, _ = self.client.wait_for_service_states(
                     ssid, self.CONNECTED_STATES, timeout_seconds - duration())
@@ -332,9 +335,18 @@
                 actual_freq = self.client.get_iw_link_value(
                         iw_runner.IW_LINK_KEY_FREQUENCY)
                 if str(freq) != actual_freq:
+                    logging.debug('Waiting for desired frequency %s (got %s).',
+                                  freq, actual_freq)
                     time.sleep(POLLING_INTERVAL_SECONDS)
                     continue
 
+            actual_subnet = self.client.wifi_ip_subnet
+            if actual_subnet != desired_subnet:
+                logging.debug('Waiting for desired subnet %s (got %s).',
+                              desired_subnet, actual_subnet)
+                time.sleep(POLLING_INTERVAL_SECONDS)
+                continue
+
             self.assert_ping_from_dut(ap_num=ap_num)
             return
 
diff --git a/server/site_linux_router.py b/server/site_linux_router.py
index afc93d8..2f382fd 100644
--- a/server/site_linux_router.py
+++ b/server/site_linux_router.py
@@ -10,6 +10,7 @@
 
 from autotest_lib.client.common_lib import error
 from autotest_lib.client.common_lib.cros.network import interface
+from autotest_lib.client.common_lib.cros.network import netblock
 from autotest_lib.server import site_linux_system
 from autotest_lib.server.cros import wifi_test_utils
 from autotest_lib.server.cros.network import hostap_config
@@ -277,34 +278,6 @@
         logging.info('AP configured.')
 
 
-    @staticmethod
-    def ip_addr(netblock, idx):
-        """Simple IPv4 calculator.
-
-        Takes host address in "IP/bits" notation and returns netmask, broadcast
-        address as well as integer offsets into the address range.
-
-        @param netblock string host address in "IP/bits" notation.
-        @param idx string describing what to return.
-        @return string containing something you hopefully requested.
-
-        """
-        addr_str,bits = netblock.split('/')
-        addr = map(int, addr_str.split('.'))
-        mask_bits = (-1 << (32-int(bits))) & 0xffffffff
-        mask = [(mask_bits >> s) & 0xff for s in range(24, -1, -8)]
-        if idx == 'local':
-            return addr_str
-        elif idx == 'netmask':
-            return '.'.join(map(str, mask))
-        elif idx == 'broadcast':
-            offset = [m ^ 0xff for m in mask]
-        else:
-            offset = [(idx >> s) & 0xff for s in range(24, -1, -8)]
-        return '.'.join(map(str, [(a & m) + o
-                                  for a, m, o in zip(addr, mask, offset)]))
-
-
     def ibss_configure(self, config):
         """Configure a station based AP in IBSS mode.
 
@@ -372,33 +345,32 @@
         @param interface string (e.g. wlan0)
 
         """
-        logging.info("Starting up local server...")
+        logging.info('Starting up local server...')
 
         if len(self.local_servers) >= 256:
             raise error.TestFail('Exhausted available local servers')
 
-        netblock = '%s/24' % self.local_server_address(len(self.local_servers))
+        server_addr = netblock.Netblock.from_addr(
+                self.local_server_address(len(self.local_servers)),
+                prefix_len=24)
 
         params = {}
-        params['netblock'] = netblock
-        params['subnet'] = self.ip_addr(netblock, 0)
-        params['netmask'] = self.ip_addr(netblock, 'netmask')
+        params['netblock'] = server_addr
         params['dhcp_range'] = ' '.join(
-            (self.ip_addr(netblock, self.dhcp_low),
-             self.ip_addr(netblock, self.dhcp_high)))
+            (server_addr.get_addr_in_block(1),
+             server_addr.get_addr_in_block(128)))
         params['interface'] = interface
-
-        params['ip_params'] = ("%s broadcast %s dev %s" %
-                               (netblock,
-                                self.ip_addr(netblock, 'broadcast'),
+        params['ip_params'] = ('%s broadcast %s dev %s' %
+                               (server_addr.netblock,
+                                server_addr.broadcast,
                                 interface))
         self.local_servers.append(params)
 
-        self.router.run("%s addr flush %s" %
+        self.router.run('%s addr flush %s' %
                         (self.cmd_ip, interface))
-        self.router.run("%s addr add %s" %
+        self.router.run('%s addr add %s' %
                         (self.cmd_ip, params['ip_params']))
-        self.router.run("%s link set %s up" %
+        self.router.run('%s link set %s up' %
                         (self.cmd_ip, interface))
         self.start_dhcp_server(interface)
 
@@ -416,13 +388,14 @@
         else:
             raise error.TestFail('Could not find local server '
                                  'to match interface: %r' % interface)
-
+        server_addr = params['netblock']
         dhcpd_conf_file = self.dhcpd_conf % interface
         dhcp_conf = '\n'.join([
             'port=0',  # disables DNS server
             'bind-interfaces',
             'log-dhcp',
-            'dhcp-range=%s' % params['dhcp_range'].replace(' ', ','),
+            'dhcp-range=%s' % ','.join((server_addr.get_addr_in_block(1),
+                                        server_addr.get_addr_in_block(128))),
             'interface=%s' % params['interface'],
             'dhcp-leasefile=%s' % self.dhcpd_leases])
         self.router.run('cat <<EOF >%s\n%s\nEOF\n' %
@@ -464,11 +437,24 @@
         @param ap_num int which local server to get an address from.
 
         """
-        if self.local_servers:
-            return self.ip_addr(self.local_servers[ap_num]['netblock'],
-                                'local')
-        else:
-            raise error.TestFail("No IP address assigned")
+        if not self.local_servers:
+            raise error.TestError('No IP address assigned')
+
+        return self.local_servers[ap_num]['netblock'].addr
+
+
+    def get_wifi_ip_subnet(self, ap_num):
+        """Return subnet of WiFi AP instance.
+
+        If no APs are configured a TestError will be raised.
+
+        @param ap_num int which local server to get an address from.
+
+        """
+        if not self.local_servers:
+            raise error.TestError('No APs configured.')
+
+        return self.local_servers[ap_num]['netblock'].subnet
 
 
     def get_hostapd_interface(self, ap_num):
diff --git a/server/site_tests/network_WiFi_Roam/network_WiFi_Roam.py b/server/site_tests/network_WiFi_Roam/network_WiFi_Roam.py
index 618a7f2..3381877 100644
--- a/server/site_tests/network_WiFi_Roam/network_WiFi_Roam.py
+++ b/server/site_tests/network_WiFi_Roam/network_WiFi_Roam.py
@@ -47,6 +47,5 @@
 
         # Expect that the DUT will re-connect to the new AP.
         self.context.wait_for_connection(router_ssid,
-                                         self._router1_conf.frequency,
-                                         ap_num=1);
+                                         self._router1_conf.frequency)
         self.context.router.deconfig()