binary_search_state.py revision a5e3929d7a155606bc541777fc84e4989389e13c
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               test_setup_script, test_script, incremental, prune, iterations,
66               prune_iterations, verify, 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.test_setup_script = test_setup_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 = verify
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 TestSetupScript(self):
172    """Run test setup script and return exit code from script."""
173    if not self.test_setup_script:
174      return 0
175
176    command = self.test_setup_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 is False this step is skipped.
186    """
187    if not self.verify:
188      return
189
190    self.l.LogOutput('VERIFICATION')
191    self.l.LogOutput('Beginning tests to verify good/bad sets\n')
192
193    self._OutputProgress('Verifying items from GOOD set\n')
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.TestSetupScript()
198      assert status == 0, 'When reset_to_good, test setup should succeed.'
199      status = self.TestScript()
200      assert status == 0, 'When reset_to_good, status should be 0.'
201
202    self._OutputProgress('Verifying items from BAD set\n')
203    with SetFile(GOOD_SET_VAR, []), SetFile(BAD_SET_VAR, self.all_items):
204      self.l.LogOutput('Resetting all items to bad to verify.')
205      self.SwitchToBad(self.all_items)
206      status = self.TestSetupScript()
207      assert status == 0, 'When reset_to_bad, test setup should succeed.'
208      status = self.TestScript()
209      assert status == 1, 'When reset_to_bad, status should be 1.'
210
211  def DoSearch(self):
212    """Perform full search for bad items.
213
214    Perform full search until prune_iterations number of bad items are found.
215    """
216    while (True and
217           len(self.all_items) > 1 and
218           self.prune_cycles < self.prune_iterations):
219      terminated = self.DoBinarySearch()
220      self.prune_cycles += 1
221      if not terminated:
222        break
223      # Prune is set.
224      prune_index = self.binary_search.current
225
226      # If found item is last item, no new items can be found
227      if prune_index == len(self.all_items) - 1:
228        self.l.LogOutput('First bad item is the last item. Breaking.')
229        self.l.LogOutput('Bad items are: %s' % self.all_items[-1])
230        break
231
232      # If already seen item we have no new bad items to find, finish up
233      if self.all_items[prune_index] in self.found_items:
234        self.l.LogOutput('Found item already found before: %s.' %
235                         self.all_items[prune_index],
236                         print_to_console=self.verbose)
237        self.l.LogOutput('No more bad items remaining. Done searching.')
238        self.l.LogOutput('Bad items are: %s' %
239                         ' '.join(self.found_items))
240        break
241
242      new_all_items = list(self.all_items)
243      # Move prune item to the end of the list.
244      new_all_items.append(new_all_items.pop(prune_index))
245      self.found_items.add(new_all_items[-1])
246
247      # Everything below newly found bad item is now known to be a good item.
248      # Take these good items out of the equation to save time on the next
249      # search. We save these known good items so they are still sent to the
250      # switch_to_good script.
251      if prune_index:
252        self.known_good.update(new_all_items[:prune_index])
253        new_all_items = new_all_items[prune_index:]
254
255      self.l.LogOutput('Old list: %s. New list: %s' % (str(self.all_items),
256                                                       str(new_all_items)),
257                       print_to_console=self.verbose)
258
259      if not self.prune:
260        self.l.LogOutput('Not continuning further, --prune is not set')
261        break
262      # FIXME: Do we need to Convert the currently good items to bad
263      self.PopulateItemsUsingList(new_all_items)
264
265  def DoBinarySearch(self):
266    """Perform single iteration of binary search."""
267    # If in resume mode don't reset search_cycles
268    if not self.resumed:
269      self.search_cycles = 0
270    else:
271      self.resumed = False
272
273    terminated = False
274    while self.search_cycles < self.iterations and not terminated:
275      self.SaveState()
276      self.OutputIterationProgress()
277
278      self.search_cycles += 1
279      [bad_items, good_items] = self.GetNextItems()
280
281      with SetFile(GOOD_SET_VAR, good_items), SetFile(BAD_SET_VAR, bad_items):
282        # TODO: bad_items should come first.
283        self.SwitchToGood(good_items)
284        self.SwitchToBad(bad_items)
285        status = self.TestSetupScript()
286        if status == 0:
287          status = self.TestScript()
288        else:
289          # Test setup script failed, treat as skipped item
290          status = 125
291        terminated = self.binary_search.SetStatus(status)
292
293      if terminated:
294        self.l.LogOutput('Terminated!', print_to_console=self.verbose)
295    if not terminated:
296      self.l.LogOutput('Ran out of iterations searching...')
297    self.l.LogOutput(str(self), print_to_console=self.verbose)
298    return terminated
299
300  def PopulateItemsUsingCommand(self, command):
301    """Update all_items and binary search logic from executable.
302
303    This method is mainly required for enumerating the initial list of items
304    from the get_initial_items script.
305
306    Args:
307      command: path to executable that will enumerate items.
308    """
309    ce = command_executer.GetCommandExecuter()
310    _, out, _ = ce.RunCommandWExceptionCleanup(command,
311                                               return_output=True,
312                                               print_to_console=self.verbose)
313    all_items = out.split()
314    self.PopulateItemsUsingList(all_items)
315
316  def PopulateItemsUsingList(self, all_items):
317    """Update all_items and binary searching logic from list.
318
319    Args:
320      all_items: new list of all_items
321    """
322    self.all_items = all_items
323    self.binary_search = binary_search_perforce.BinarySearcher(
324        logger_to_set=self.l)
325    self.binary_search.SetSortedList(self.all_items)
326
327  def SaveState(self):
328    """Save state to STATE_FILE.
329
330    SaveState will create a new unique, hidden state file to hold data from
331    object. Then atomically overwrite the STATE_FILE symlink to point to the
332    new data.
333
334    Raises:
335      Error if STATE_FILE already exists but is not a symlink.
336    """
337    ce, l = self.ce, self.l
338    self.ce, self.l, self.binary_search.logger = None, None, None
339    old_state = None
340
341    _, path = tempfile.mkstemp(prefix=HIDDEN_STATE_FILE, dir='.')
342    with open(path, 'wb') as f:
343      pickle.dump(self, f)
344
345    if os.path.exists(STATE_FILE):
346      if os.path.islink(STATE_FILE):
347        old_state = os.readlink(STATE_FILE)
348      else:
349        raise Error(('%s already exists and is not a symlink!\n'
350                     'State file saved to %s' % (STATE_FILE, path)))
351
352    # Create new link and atomically overwrite old link
353    temp_link = '%s.link' % HIDDEN_STATE_FILE
354    os.symlink(path, temp_link)
355    os.rename(temp_link, STATE_FILE)
356
357    if old_state:
358      os.remove(old_state)
359
360    self.ce, self.l, self.binary_search.logger = ce, l, l
361
362  @classmethod
363  def LoadState(cls):
364    """Create BinarySearchState object from STATE_FILE."""
365    if not os.path.isfile(STATE_FILE):
366      return None
367    try:
368      bss = pickle.load(file(STATE_FILE))
369      bss.l = logger.GetLogger()
370      bss.ce = command_executer.GetCommandExecuter()
371      bss.binary_search.logger = bss.l
372      bss.start_time = time.time()
373      bss.resumed = True
374      binary_search_perforce.verbose = bss.verbose
375      return bss
376    except StandardError:
377      return None
378
379  def RemoveState(self):
380    """Remove STATE_FILE and its symlinked data from file system."""
381    if os.path.exists(STATE_FILE):
382      if os.path.islink(STATE_FILE):
383        real_file = os.readlink(STATE_FILE)
384        os.remove(real_file)
385        os.remove(STATE_FILE)
386
387  def GetNextItems(self):
388    """Get next items for binary search based on result of the last test run."""
389    border_item = self.binary_search.GetNext()
390    index = self.all_items.index(border_item)
391
392    next_bad_items = self.all_items[:index + 1]
393    next_good_items = self.all_items[index + 1:] + list(self.known_good)
394
395    return [next_bad_items, next_good_items]
396
397  def ElapsedTimeString(self):
398    """Return h m s format of elapsed time since execution has started."""
399    diff = int(time.time() - self.start_time)
400    seconds = diff % 60
401    minutes = (diff / 60) % 60
402    hours = diff / (60 * 60)
403
404    seconds = str(seconds).rjust(2)
405    minutes = str(minutes).rjust(2)
406    hours = str(hours).rjust(2)
407
408    return '%sh %sm %ss' % (hours, minutes, seconds)
409
410  def _OutputProgress(self, progress_text):
411    """Output current progress of binary search to console and logs.
412
413    Args:
414      progress_text: The progress to display to the user.
415    """
416    progress = ('\n***** PROGRESS (elapsed time: %s) *****\n'
417                '%s'
418                '************************************************')
419    progress = progress % (self.ElapsedTimeString(), progress_text)
420    self.l.LogOutput(progress)
421
422  def OutputIterationProgress(self):
423    out = ('Search %d of estimated %d.\n'
424           'Prune %d of max %d.\n'
425           'Current bad items found:\n'
426           '%s\n')
427    out = out % (self.search_cycles + 1,
428                 math.ceil(math.log(len(self.all_items), 2)),
429                 self.prune_cycles + 1,
430                 self.prune_iterations,
431                 ', '.join(self.found_items))
432    self._OutputProgress(out)
433
434  def __str__(self):
435    ret = ''
436    ret += 'all: %s\n' % str(self.all_items)
437    ret += 'currently_good: %s\n' % str(self.currently_good_items)
438    ret += 'currently_bad: %s\n' % str(self.currently_bad_items)
439    ret += str(self.binary_search)
440    return ret
441
442
443class MockBinarySearchState(BinarySearchState):
444  """Mock class for BinarySearchState."""
445
446  def __init__(self, **kwargs):
447    # Initialize all arguments to None
448    default_kwargs = {
449        'get_initial_items': 'echo "1"',
450        'switch_to_good': None,
451        'switch_to_bad': None,
452        'test_setup_script': None,
453        'test_script': None,
454        'incremental': True,
455        'prune': False,
456        'iterations': 50,
457        'prune_iterations': 100,
458        'verify': True,
459        'file_args': False,
460        'verbose': False
461    }
462    default_kwargs.update(kwargs)
463    super(MockBinarySearchState, self).__init__(**default_kwargs)
464
465
466def _CanonicalizeScript(script_name):
467  """Return canonical path to script.
468
469  Args:
470    script_name: Relative or absolute path to script
471
472  Returns:
473    Canonicalized script path
474  """
475  script_name = os.path.expanduser(script_name)
476  if not script_name.startswith('/'):
477    return os.path.join('.', script_name)
478
479
480def Run(get_initial_items, switch_to_good, switch_to_bad, test_script,
481        test_setup_script=None, iterations=50, prune=False, noincremental=False,
482        file_args=False, verify=True, prune_iterations=100, verbose=False,
483        resume=False):
484  """Run binary search tool. Equivalent to running through terminal.
485
486  Args:
487    get_initial_items: Script to enumerate all items being binary searched
488    switch_to_good: Script that will take items as input and switch them to good
489                    set
490    switch_to_bad: Script that will take items as input and switch them to bad
491                   set
492    test_script: Script that will determine if the current combination of good
493                 and bad items make a "good" or "bad" result.
494    test_setup_script: Script to do necessary setup (building, compilation,
495                       etc.) for test_script.
496    iterations: How many binary search iterations to run before exiting.
497    prune: If False the binary search tool will stop when the first bad item is
498           found. Otherwise then binary search tool will continue searching
499           until all bad items are found (or prune_iterations is reached).
500    noincremental: Whether to send "diffs" of good/bad items to switch scripts.
501    file_args: If True then arguments to switch scripts will be a file name
502               containing a newline separated list of the items to switch.
503    verify: If True, run tests to ensure initial good/bad sets actually
504            produce a good/bad result.
505    prune_iterations: Max number of bad items to search for.
506    verbose: If True will print extra debug information to user.
507    resume: If True will resume using STATE_FILE.
508
509  Returns:
510    0 for success, error otherwise
511  """
512  if resume:
513    bss = BinarySearchState.LoadState()
514    if not bss:
515      logger.GetLogger().LogOutput(
516          '%s is not a valid binary_search_tool state file, cannot resume!' %
517          STATE_FILE)
518      return 1
519  else:
520    switch_to_good = _CanonicalizeScript(switch_to_good)
521    switch_to_bad = _CanonicalizeScript(switch_to_bad)
522    if test_setup_script:
523      test_setup_script = _CanonicalizeScript(test_setup_script)
524    test_script = _CanonicalizeScript(test_script)
525    get_initial_items = _CanonicalizeScript(get_initial_items)
526    incremental = not noincremental
527
528    binary_search_perforce.verbose = verbose
529
530    bss = BinarySearchState(get_initial_items, switch_to_good, switch_to_bad,
531                            test_setup_script, test_script, incremental, prune,
532                            iterations, prune_iterations, verify, file_args,
533                            verbose)
534    bss.DoVerify()
535
536  try:
537    bss.DoSearch()
538    bss.RemoveState()
539    logger.GetLogger().LogOutput('Total execution time: %s' %
540                                 bss.ElapsedTimeString())
541  except Error as e:
542    logger.GetLogger().LogError(e)
543    return 1
544
545  return 0
546
547
548def Main(argv):
549  """The main function."""
550  # Common initializations
551
552  parser = argparse.ArgumentParser()
553  common.BuildArgParser(parser)
554  logger.GetLogger().LogOutput(' '.join(argv))
555  options = parser.parse_args(argv)
556
557  if not (options.get_initial_items and options.switch_to_good and
558          options.switch_to_bad and options.test_script) and not options.resume:
559    parser.print_help()
560    return 1
561
562  if options.resume:
563    logger.GetLogger().LogOutput('Resuming from %s' % STATE_FILE)
564    if len(argv) > 1:
565      logger.GetLogger().LogOutput(('Note: resuming from previous state, '
566                                    'ignoring given options and loading saved '
567                                    'options instead.'))
568
569  # Get dictionary of all options
570  args = vars(options)
571  return Run(**args)
572
573
574if __name__ == '__main__':
575  sys.exit(Main(sys.argv[1:]))
576