binary_search_state.py revision 660e682dae1e34076cb758694be67edfc7bc6ed4
1#!/usr/bin/python2
2"""The binary search wrapper."""
3
4from __future__ import print_function
5
6import argparse
7import contextlib
8import errno
9import math
10import os
11import pickle
12import sys
13import tempfile
14import time
15
16# Adds cros_utils to PYTHONPATH
17import common
18
19# Now we do import from cros_utils
20from cros_utils import command_executer
21from cros_utils import logger
22
23import binary_search_perforce
24
25GOOD_SET_VAR = 'BISECT_GOOD_SET'
26BAD_SET_VAR = 'BISECT_BAD_SET'
27
28STATE_FILE = '%s.state' % sys.argv[0]
29HIDDEN_STATE_FILE = os.path.join(
30    os.path.dirname(STATE_FILE), '.%s' % os.path.basename(STATE_FILE))
31
32class Error(Exception):
33  """The general binary search tool error class."""
34  pass
35
36
37@contextlib.contextmanager
38def SetFile(env_var, items):
39  """Generate set files that can be used by switch/test scripts.
40
41  Generate temporary set file (good/bad) holding contents of good/bad items for
42  the current binary search iteration. Store the name of each file as an
43  environment variable so all child processes can access it.
44
45  This function is a contextmanager, meaning it's meant to be used with the
46  "with" statement in Python. This is so cleanup and setup happens automatically
47  and cleanly. Execution of the outer "with" statement happens at the "yield"
48  statement.
49
50  Args:
51    env_var: What environment variable to store the file name in.
52    items: What items are in this set.
53  """
54  with tempfile.NamedTemporaryFile() as f:
55    os.environ[env_var] = f.name
56    f.write('\n'.join(items))
57    f.flush()
58    yield
59
60
61class BinarySearchState(object):
62  """The binary search state class."""
63
64  def __init__(self, get_initial_items, switch_to_good, switch_to_bad,
65               install_script, test_script, incremental, prune, iterations,
66               prune_iterations, verify_level, file_args, verbose):
67    """BinarySearchState constructor, see Run for full args documentation."""
68    self.get_initial_items = get_initial_items
69    self.switch_to_good = switch_to_good
70    self.switch_to_bad = switch_to_bad
71    self.install_script = install_script
72    self.test_script = test_script
73    self.incremental = incremental
74    self.prune = prune
75    self.iterations = iterations
76    self.prune_iterations = prune_iterations
77    self.verify_level = verify_level
78    self.file_args = file_args
79    self.verbose = verbose
80
81    self.l = logger.GetLogger()
82    self.ce = command_executer.GetCommandExecuter()
83
84    self.resumed = False
85    self.prune_cycles = 0
86    self.search_cycles = 0
87    self.binary_search = None
88    self.all_items = None
89    self.PopulateItemsUsingCommand(self.get_initial_items)
90    self.currently_good_items = set([])
91    self.currently_bad_items = set([])
92    self.found_items = set([])
93    self.known_good = set([])
94
95    self.start_time = time.time()
96
97  def SwitchToGood(self, item_list):
98    """Switch given items to "good" set."""
99    if self.incremental:
100      self.l.LogOutput('Incremental set. Wanted to switch %s to good' %
101                       str(item_list), print_to_console=self.verbose)
102      incremental_items = [
103          item for item in item_list if item not in self.currently_good_items
104      ]
105      item_list = incremental_items
106      self.l.LogOutput('Incremental set. Actually switching %s to good' %
107                       str(item_list), print_to_console=self.verbose)
108
109    if not item_list:
110      return
111
112    self.l.LogOutput('Switching %s to good' % str(item_list),
113                     print_to_console=self.verbose)
114    self.RunSwitchScript(self.switch_to_good, item_list)
115    self.currently_good_items = self.currently_good_items.union(set(item_list))
116    self.currently_bad_items.difference_update(set(item_list))
117
118  def SwitchToBad(self, item_list):
119    """Switch given items to "bad" set."""
120    if self.incremental:
121      self.l.LogOutput('Incremental set. Wanted to switch %s to bad' %
122                       str(item_list), print_to_console=self.verbose)
123      incremental_items = [
124          item for item in item_list if item not in self.currently_bad_items
125      ]
126      item_list = incremental_items
127      self.l.LogOutput('Incremental set. Actually switching %s to bad' %
128                       str(item_list), print_to_console=self.verbose)
129
130    if not item_list:
131      return
132
133    self.l.LogOutput('Switching %s to bad' % str(item_list),
134                     print_to_console=self.verbose)
135    self.RunSwitchScript(self.switch_to_bad, item_list)
136    self.currently_bad_items = self.currently_bad_items.union(set(item_list))
137    self.currently_good_items.difference_update(set(item_list))
138
139  def RunSwitchScript(self, switch_script, item_list):
140    """Pass given items to switch script.
141
142    Args:
143      switch_script: path to switch script
144      item_list: list of all items to be switched
145    """
146    if self.file_args:
147      with tempfile.NamedTemporaryFile() as f:
148        f.write('\n'.join(item_list))
149        f.flush()
150        command = '%s %s' % (switch_script, f.name)
151        ret, _, _ = self.ce.RunCommandWExceptionCleanup(
152            command, print_to_console=self.verbose)
153    else:
154      command = '%s %s' % (switch_script, ' '.join(item_list))
155      try:
156        ret, _, _ = self.ce.RunCommandWExceptionCleanup(
157            command, print_to_console=self.verbose)
158      except OSError as e:
159        if e.errno == errno.E2BIG:
160          raise Error('Too many arguments for switch script! Use --file_args')
161        else:
162          raise
163    assert ret == 0, 'Switch script %s returned %d' % (switch_script, ret)
164
165  def TestScript(self):
166    """Run test script and return exit code from script."""
167    command = self.test_script
168    ret, _, _ = self.ce.RunCommandWExceptionCleanup(command)
169    return ret
170
171  def InstallScript(self):
172    """Run install script and return exit code from script."""
173    if not self.install_script:
174      return 0
175
176    command = self.install_script
177    ret, _, _ = self.ce.RunCommandWExceptionCleanup(command)
178    return ret
179
180  def DoVerify(self):
181    """Verify correctness of test environment.
182
183    Verify that a "good" set of items produces a "good" result and that a "bad"
184    set of items produces a "bad" result. To be run directly before running
185    DoSearch. If verify_level is 0 this step is skipped.
186    """
187    if not self.verify_level:
188      return
189
190    self.l.LogOutput('VERIFICATION')
191    self.l.LogOutput('Beginning %d tests to verify good/bad sets\n' %
192                     self.verify_level)
193    for _ in range(int(self.verify_level)):
194      with SetFile(GOOD_SET_VAR, self.all_items), SetFile(BAD_SET_VAR, []):
195        self.l.LogOutput('Resetting all items to good to verify.')
196        self.SwitchToGood(self.all_items)
197        status = self.InstallScript()
198        assert status == 0, 'When reset_to_good, install should succeed.'
199        status = self.TestScript()
200        assert status == 0, 'When reset_to_good, status should be 0.'
201
202      with SetFile(GOOD_SET_VAR, []), SetFile(BAD_SET_VAR, self.all_items):
203        self.l.LogOutput('Resetting all items to bad to verify.')
204        self.SwitchToBad(self.all_items)
205        status = self.InstallScript()
206        assert status == 0, 'When reset_to_bad, install should succeed.'
207        status = self.TestScript()
208        assert status == 1, 'When reset_to_bad, status should be 1.'
209
210  def DoSearch(self):
211    """Perform full search for bad items.
212
213    Perform full search until prune_iterations number of bad items are found.
214    """
215    while (True and
216           len(self.all_items) > 1 and
217           self.prune_cycles < self.prune_iterations):
218      terminated = self.DoBinarySearch()
219      self.prune_cycles += 1
220      if not terminated:
221        break
222      # Prune is set.
223      prune_index = self.binary_search.current
224
225      # If found item is last item, no new items can be found
226      if prune_index == len(self.all_items) - 1:
227        self.l.LogOutput('First bad item is the last item. Breaking.')
228        self.l.LogOutput('Bad items are: %s' % self.all_items[-1])
229        break
230
231      # If already seen item we have no new bad items to find, finish up
232      if self.all_items[prune_index] in self.found_items:
233        self.l.LogOutput(('Found item already found before: %s. '
234                          'Done searching.' % self.all_items[prune_index]))
235        self.l.LogOutput('Bad items are: %s' %
236                         ' '.join(self.found_items))
237        break
238
239      new_all_items = list(self.all_items)
240      # Move prune item to the end of the list.
241      new_all_items.append(new_all_items.pop(prune_index))
242      self.found_items.add(new_all_items[-1])
243
244      # Everything below newly found bad item is now known to be a good item.
245      # Take these good items out of the equation to save time on the next
246      # search. We save these known good items so they are still sent to the
247      # switch_to_good script.
248      if prune_index:
249        self.known_good.update(new_all_items[:prune_index])
250        new_all_items = new_all_items[prune_index:]
251
252      self.l.LogOutput('Old list: %s. New list: %s' % (str(self.all_items),
253                                                       str(new_all_items)),
254                       print_to_console=self.verbose)
255
256      if not self.prune:
257        self.l.LogOutput('Not continuning further, --prune is not set')
258        break
259      # FIXME: Do we need to Convert the currently good items to bad
260      self.PopulateItemsUsingList(new_all_items)
261
262  def DoBinarySearch(self):
263    """Perform single iteration of binary search."""
264    # If in resume mode don't reset search_cycles
265    if not self.resumed:
266      self.search_cycles = 0
267    else:
268      self.resumed = False
269
270    terminated = False
271    while self.search_cycles < self.iterations and not terminated:
272      self.SaveState()
273      self.OutputProgress()
274
275      self.search_cycles += 1
276      [bad_items, good_items] = self.GetNextItems()
277
278      with SetFile(GOOD_SET_VAR, good_items), SetFile(BAD_SET_VAR, bad_items):
279        # TODO: bad_items should come first.
280        self.SwitchToGood(good_items)
281        self.SwitchToBad(bad_items)
282        status = self.InstallScript()
283        if status == 0:
284          status = self.TestScript()
285        else:
286          # Install script failed, treat as skipped item
287          status = 2
288        terminated = self.binary_search.SetStatus(status)
289
290      if terminated:
291        self.l.LogOutput('Terminated!')
292    if not terminated:
293      self.l.LogOutput('Ran out of iterations searching...')
294    self.l.LogOutput(str(self), print_to_console=self.verbose)
295    return terminated
296
297  def PopulateItemsUsingCommand(self, command):
298    """Update all_items and binary search logic from executable.
299
300    This method is mainly required for enumerating the initial list of items
301    from the get_initial_items script.
302
303    Args:
304      command: path to executable that will enumerate items.
305    """
306    ce = command_executer.GetCommandExecuter()
307    _, out, _ = ce.RunCommandWExceptionCleanup(command,
308                                               return_output=True,
309                                               print_to_console=self.verbose)
310    all_items = out.split()
311    self.PopulateItemsUsingList(all_items)
312
313  def PopulateItemsUsingList(self, all_items):
314    """Update all_items and binary searching logic from list.
315
316    Args:
317      all_items: new list of all_items
318    """
319    self.all_items = all_items
320    self.binary_search = binary_search_perforce.BinarySearcher(
321        logger_to_set=self.l)
322    self.binary_search.SetSortedList(self.all_items)
323
324  def SaveState(self):
325    """Save state to STATE_FILE.
326
327    SaveState will create a new unique, hidden state file to hold data from
328    object. Then atomically overwrite the STATE_FILE symlink to point to the
329    new data.
330
331    Raises:
332      Error if STATE_FILE already exists but is not a symlink.
333    """
334    ce, l = self.ce, self.l
335    self.ce, self.l, self.binary_search.logger = None, None, None
336    old_state = None
337
338    _, path = tempfile.mkstemp(prefix=HIDDEN_STATE_FILE, dir='.')
339    with open(path, 'wb') as f:
340      pickle.dump(self, f)
341
342    if os.path.exists(STATE_FILE):
343      if os.path.islink(STATE_FILE):
344        old_state = os.readlink(STATE_FILE)
345      else:
346        raise Error(('%s already exists and is not a symlink!\n'
347                     'State file saved to %s' % (STATE_FILE, path)))
348
349    # Create new link and atomically overwrite old link
350    temp_link = '%s.link' % HIDDEN_STATE_FILE
351    os.symlink(path, temp_link)
352    os.rename(temp_link, STATE_FILE)
353
354    if old_state:
355      os.remove(old_state)
356
357    self.ce, self.l, self.binary_search.logger = ce, l, l
358
359  @classmethod
360  def LoadState(cls):
361    """Create BinarySearchState object from STATE_FILE."""
362    if not os.path.isfile(STATE_FILE):
363      return None
364    try:
365      bss = pickle.load(file(STATE_FILE))
366      bss.l = logger.GetLogger()
367      bss.ce = command_executer.GetCommandExecuter()
368      bss.binary_search.logger = bss.l
369      bss.start_time = time.time()
370      bss.resumed = True
371      binary_search_perforce.verbose = bss.verbose
372      return bss
373    except StandardError:
374      return None
375
376  def RemoveState(self):
377    """Remove STATE_FILE and its symlinked data from file system."""
378    if os.path.exists(STATE_FILE):
379      if os.path.islink(STATE_FILE):
380        real_file = os.readlink(STATE_FILE)
381        os.remove(real_file)
382        os.remove(STATE_FILE)
383
384  def GetNextItems(self):
385    """Get next items for binary search based on result of the last test run."""
386    border_item = self.binary_search.GetNext()
387    index = self.all_items.index(border_item)
388
389    next_bad_items = self.all_items[:index + 1]
390    next_good_items = self.all_items[index + 1:] + list(self.known_good)
391
392    return [next_bad_items, next_good_items]
393
394  def ElapsedTimeString(self):
395    """Return h m s format of elapsed time since execution has started."""
396    diff = int(time.time() - self.start_time)
397    seconds = diff % 60
398    minutes = (diff / 60) % 60
399    hours = diff / (60 * 60)
400
401    seconds = str(seconds).rjust(2)
402    minutes = str(minutes).rjust(2)
403    hours = str(hours).rjust(2)
404
405    return '%sh %sm %ss' % (hours, minutes, seconds)
406
407  def OutputProgress(self):
408    """Output current progress of binary search to console and logs."""
409    out = ('\n***** PROGRESS (elapsed time: %s) *****\n'
410           'Search %d of estimated %d.\n'
411           'Prune %d of max %d.\n'
412           'Current bad items found:\n'
413           '%s\n'
414           '************************************************')
415    out = out % (self.ElapsedTimeString(),
416                 self.search_cycles + 1,
417                 math.ceil(math.log(len(self.all_items), 2)),
418                 self.prune_cycles + 1,
419                 self.prune_iterations,
420                 str(self.found_items))
421
422    self.l.LogOutput(out)
423
424  def __str__(self):
425    ret = ''
426    ret += 'all: %s\n' % str(self.all_items)
427    ret += 'currently_good: %s\n' % str(self.currently_good_items)
428    ret += 'currently_bad: %s\n' % str(self.currently_bad_items)
429    ret += str(self.binary_search)
430    return ret
431
432
433class MockBinarySearchState(BinarySearchState):
434  """Mock class for BinarySearchState."""
435
436  def __init__(self, **kwargs):
437    # Initialize all arguments to None
438    default_kwargs = {
439        'get_initial_items': 'echo "1"',
440        'switch_to_good': None,
441        'switch_to_bad': None,
442        'install_script': None,
443        'test_script': None,
444        'incremental': True,
445        'prune': False,
446        'iterations': 50,
447        'prune_iterations': 100,
448        'verify_level': 1,
449        'file_args': False,
450        'verbose': False
451    }
452    default_kwargs.update(kwargs)
453    super(MockBinarySearchState, self).__init__(**default_kwargs)
454
455
456def _CanonicalizeScript(script_name):
457  """Return canonical path to script.
458
459  Args:
460    script_name: Relative or absolute path to script
461
462  Returns:
463    Canonicalized script path
464  """
465  script_name = os.path.expanduser(script_name)
466  if not script_name.startswith('/'):
467    return os.path.join('.', script_name)
468
469
470def Run(get_initial_items, switch_to_good, switch_to_bad, test_script,
471        install_script=None, iterations=50, prune=True, noincremental=False,
472        file_args=False, verify_level=1, prune_iterations=100, verbose=False,
473        resume=False):
474  """Run binary search tool. Equivalent to running through terminal.
475
476  Args:
477    get_initial_items: Script to enumerate all items being binary searched
478    switch_to_good: Script that will take items as input and switch them to good
479                    set
480    switch_to_bad: Script that will take items as input and switch them to bad
481                   set
482    test_script: Script that will determine if the current combination of good
483                 and bad items make a "good" or "bad" result.
484    install_script: Script to do necessary setup (building, compilation, etc.)
485                    for test_script
486    iterations: How many binary search iterations to run before exiting.
487    prune: If False the binary search tool will stop when the first bad item is
488           found. Otherwise then binary search tool will continue searching
489           until all bad items are found (or prune_iterations is reached).
490    noincremental: Whether to send "diffs" of good/bad items to switch scripts.
491    file_args: If True then arguments to switch scripts will be a file name
492               containing a newline separated list of the items to switch.
493    verify_level: How many verification tests to run to ensure initial good/bad
494                  sets actually produce a good/bad result.
495    prune_iterations: Max number of bad items to search for.
496    verbose: If True will print extra debug information to user.
497    resume: If True will resume using STATE_FILE.
498
499  Returns:
500    0 for success, error otherwise
501  """
502  if resume:
503    bss = BinarySearchState.LoadState()
504    if not bss:
505      logger.GetLogger().LogOutput(
506          '%s is not a valid binary_search_tool state file, cannot resume!' %
507          STATE_FILE)
508      return 1
509  else:
510    switch_to_good = _CanonicalizeScript(switch_to_good)
511    switch_to_bad = _CanonicalizeScript(switch_to_bad)
512    if install_script:
513      install_script = _CanonicalizeScript(install_script)
514    test_script = _CanonicalizeScript(test_script)
515    get_initial_items = _CanonicalizeScript(get_initial_items)
516    incremental = not noincremental
517
518    binary_search_perforce.verbose = verbose
519
520    bss = BinarySearchState(get_initial_items, switch_to_good, switch_to_bad,
521                            install_script, test_script, incremental, prune,
522                            iterations, prune_iterations, verify_level,
523                            file_args, verbose)
524    bss.DoVerify()
525
526  try:
527    bss.DoSearch()
528    bss.RemoveState()
529    logger.GetLogger().LogOutput('Total execution time: %s' %
530                                 bss.ElapsedTimeString())
531  except Error as e:
532    logger.GetLogger().LogError(e)
533    return 1
534
535  return 0
536
537
538def Main(argv):
539  """The main function."""
540  # Common initializations
541
542  parser = argparse.ArgumentParser()
543  common.BuildArgParser(parser)
544  logger.GetLogger().LogOutput(' '.join(argv))
545  options = parser.parse_args(argv)
546
547  if not (options.get_initial_items and options.switch_to_good and
548          options.switch_to_bad and options.test_script) and not options.resume:
549    parser.print_help()
550    return 1
551
552  if options.resume:
553    logger.GetLogger().LogOutput('Resuming from %s' % STATE_FILE)
554    if len(argv) > 1:
555      logger.GetLogger().LogOutput(('Note: resuming from previous state, '
556                                    'ignoring given options and loading saved '
557                                    'options instead.'))
558
559  # Get dictionary of all options
560  args = vars(options)
561  return Run(**args)
562
563
564if __name__ == '__main__':
565  sys.exit(Main(sys.argv[1:]))
566