Commit 68ef4428 authored by Sergiy Belozorov's avatar Sergiy Belozorov Committed by Commit Bot

Implement auto command in the update_wpr script

R=perezju@chromium.org

Bug: 895891
Change-Id: Ib4b6b0ea123b64d0333cb0911930358dcb3ff2ad
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1496871
Commit-Queue: Sergiy Belozorov <sergiyb@chromium.org>
Reviewed-by: default avatarJuan Antonio Navarro Pérez <perezju@chromium.org>
Cr-Commit-Position: refs/heads/master@{#637677}
parent 6d77a353
...@@ -9,10 +9,13 @@ import argparse ...@@ -9,10 +9,13 @@ import argparse
import datetime import datetime
import json import json
import os import os
import random
import re import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import time
import webbrowser
from core import cli_helpers from core import cli_helpers
from core import path_util from core import path_util
...@@ -24,7 +27,7 @@ from services import request # pylint: disable=import-error ...@@ -24,7 +27,7 @@ from services import request # pylint: disable=import-error
SRC_ROOT = os.path.abspath( SRC_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', '..', '..')) os.path.join(os.path.dirname(__file__), '..', '..', '..', '..'))
RESULTS2JSON = os.path.join( RESULTS2JSON = os.path.join(
SRC_ROOT, 'third_party', 'catapult', 'tracing', 'bin', 'results2json') SRC_ROOT, 'third_party', 'catapult', 'tracing', 'bin', 'results2json')
HISTOGRAM2CSV = os.path.join( HISTOGRAM2CSV = os.path.join(
...@@ -35,6 +38,41 @@ RECORD_WPR = os.path.join(SRC_ROOT, 'tools', 'perf', 'record_wpr') ...@@ -35,6 +38,41 @@ RECORD_WPR = os.path.join(SRC_ROOT, 'tools', 'perf', 'record_wpr')
DEFAULT_REVIEWERS = ['perezju@chromium.org'] DEFAULT_REVIEWERS = ['perezju@chromium.org']
def _GetBranchName():
return subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
def _OpenBrowser(url):
# Redirect I/O before invoking browser to avoid it spamming our output.
# Based on https://stackoverflow.com/a/2323563.
savout = os.dup(1)
saverr = os.dup(2)
os.close(1)
os.close(2)
os.open(os.devnull, os.O_RDWR)
try:
webbrowser.open(url)
finally:
os.dup2(savout, 1)
os.dup2(saverr, 2)
def _SendCLForReview(comment):
subprocess.check_call(
['git', 'cl', 'comments', '--publish', '--add-comment', comment])
def _EnsureEditor():
if 'EDITOR' not in os.environ:
os.environ['EDITOR'] = cli_helpers.Prompt(
'Looks like EDITOR environment varible is not defined. Please enter '
'the command to view logs: ')
def _OpenEditor(filepath):
subprocess.check_call([os.environ['EDITOR'], filepath])
class WprUpdater(object): class WprUpdater(object):
def __init__(self, args): def __init__(self, args):
self.story = args.story self.story = args.story
...@@ -226,6 +264,48 @@ class WprUpdater(object): ...@@ -226,6 +264,48 @@ class WprUpdater(object):
with open(output_file, 'r') as output_fd: with open(output_file, 'r') as output_fd:
return json.load(output_fd)['issue_url'] return json.load(output_fd)['issue_url']
def _CreateBranch(self):
sanitized_story = re.sub(r'[^A-Za-z0-9-_.]', r'-', self.story)
subprocess.check_call([
'git', 'new-branch',
'update-wpr-%s-%d' % (sanitized_story, random.randint(0, 10000)),
])
def _FilterLogForDiff(self, log_filename):
"""Removes unimportant details from console logs for cleaner diffs.
For example, log line from file `log_filename`
2018-02-01 22:23:22,123 operation abcdef01-abcd-abcd-0123-abcdef012345
from /tmp/tmpX34v/results.html took 22145ms when accessed via
https://127.0.0.1:1233/endpoint
would become
<timestamp> operation <guid> from /tmp/tmp<random>/results.html took
<duration> when accessed via https://127.0.0.1:<port>/endpoint
Returns:
Path to the filtered log.
"""
with open(log_filename) as src, tempfile.NamedTemporaryFile(
suffix='diff', dir=self.output_dir, delete=False) as dest:
for line in src:
# Remove timestamps.
line = re.sub(
r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}', r'<timestamp>', line)
# Remove GUIDs.
line = re.sub(
r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', r'<guid>', line)
# Remove random letters in paths to temp dirs and files.
line = re.sub(r'(/tmp/tmp)[^/\s]+', r'\1<random>', line)
# Remove random port in localhost URLs.
line = re.sub(r'(://127.0.0.1:)\d+', r'\1<port>', line)
# Remove random durations in ms.
line = re.sub(r'\d+ ms', r'<duration>', line)
dest.write(line)
return dest.name
def _StartPinpointJob(self, configuration): def _StartPinpointJob(self, configuration):
"""Creates, starts a Pinpoint job and returns its URL.""" """Creates, starts a Pinpoint job and returns its URL."""
try: try:
...@@ -328,6 +408,153 @@ class WprUpdater(object): ...@@ -328,6 +408,153 @@ class WprUpdater(object):
job_urls.append(job_url) job_urls.append(job_url)
return job_urls, failed_configs return job_urls, failed_configs
def AutoRun(self):
# Let the quest begin...
cli_helpers.Comment(
'This script will help you update the recording of a story. It will go '
'through the following stages, which you can also invoke manually via '
'subcommand specified in parentheses:')
cli_helpers.Comment(' - help create a new branch if needed')
cli_helpers.Comment(' - run story with live network connection (live)')
cli_helpers.Comment(' - record story (record)')
cli_helpers.Comment(' - replay the recording (replay)')
cli_helpers.Comment(' - upload the recording to Google Storage (upload)')
cli_helpers.Comment(
' - upload CL with updated recording reference (review)')
cli_helpers.Comment(' - trigger pinpoint tryjobs (pinpoint)')
cli_helpers.Comment(' - post links to these jobs on the CL')
cli_helpers.Comment(
'Note that you can always enter prefix of the answer to any of the '
'questions asked below, e.g. "y" for "yes" or "j" for "just-replay".')
# TODO(sergiyb): Detect if benchmark is not implemented and try to add it
# automatically by copying the same benchmark without :<current-year> suffix
# and changing name of the test, name of the benchmark and the year tag.
# Create branch if needed.
reuse_cl = False
branch = _GetBranchName()
if branch == 'HEAD':
cli_helpers.Comment('You are not on a branch.')
if not cli_helpers.Ask(
'Should script create a new branch automatically?'):
cli_helpers.Comment(
'Please create a new branch and start this script again')
return
self._CreateBranch()
else:
issue = self._GetBranchIssueUrl()
if issue is not None:
issue_message = 'with an associated issue: %s' % issue
else:
issue_message = 'without an associated issue'
cli_helpers.Comment(
'You are on a branch {branch} {issue_message}. Please commit or '
'stash any changes unrelated to the updated story before '
'proceeding.', branch=branch, issue_message=issue_message)
ans = cli_helpers.Ask(
'Should the script create a new branch automatically, reuse '
'existing one or exit?', answers=['create', 'reuse', 'exit'],
default='create')
if ans == 'create':
self._CreateBranch()
elif ans == 'reuse':
reuse_cl = issue is not None
elif ans == 'exit':
return
# Live run.
live_out_file = self.LiveRun()
cli_helpers.Comment(
'Please inspect the live run results above for any errors.')
ans = None
while ans != 'continue':
ans = cli_helpers.Ask(
'Should I continue with recording, view metric results in a browser, '
'view stdout/stderr output or stop?',
['continue', 'metrics', 'output', 'stop'], default='continue')
if ans == 'stop':
cli_helpers.Comment(
'Please update the story class to resolve the observed issues and '
'then run this script again.')
return
elif ans == 'metrics':
_OpenBrowser('file://%s.results.html' % live_out_file)
elif ans == 'output':
_OpenEditor(live_out_file)
# Record & replay.
action = 'record'
while action != 'continue':
if action == 'record':
self.RecordWpr()
if action in ['record', 'just-replay']:
replay_out_file = self.ReplayWpr()
cli_helpers.Comment(
'Check that the console:error:all metrics above have low values '
'and are similar to the live run above.')
if action == 'diff':
diff_path = os.path.join(self.output_dir, 'live_replay.diff')
with open(diff_path, 'w') as diff_file:
subprocess.call([
'diff', '--color', self._FilterLogForDiff(live_out_file),
self._FilterLogForDiff(replay_out_file)], stdout=diff_file)
_OpenEditor(diff_path)
if action == 'stop':
return
action = cli_helpers.Ask(
'Should I record and replay again, just replay, continue with '
'uploading CL, stop and exit, or would you prefer to see diff '
'between live/replay console logs?',
['record', 'just-replay', 'continue', 'stop', 'diff'],
default='continue')
# Upload WPR and create a WIP CL for the new story.
if not self.UploadWpr():
return
while self.UploadCL(short_description=reuse_cl) != 0:
if not cli_helpers.Ask('Upload failed. Should I try again?'):
return
# Gerrit needs some time to sync its backends, hence we sleep here for 5
# seconds. Otherwise, pinpoint app may get an answer that the CL that we've
# just uploaded does not exist yet.
cli_helpers.Comment(
'Waiting 20 seconds for the Gerrit backends to sync, so that Pinpoint '
'app can detect the newly-created CL.')
time.sleep(20)
# Trigger pinpoint jobs.
configs_to_trigger = None
job_urls = []
while True:
new_job_urls, configs_to_trigger = self.StartPinpointJobs(
configs_to_trigger)
job_urls.extend(new_job_urls)
if not configs_to_trigger or not cli_helpers.Ask(
'Do you want to try triggering the failed configs again?'):
break
if configs_to_trigger:
if not cli_helpers.Ask(
'Some jobs failed to trigger. Do you still want to send created '
'CL for review?', default='no'):
return
# Post a link to the triggered jobs, publish CL for review and open it.
_SendCLForReview(
'Started the following Pinpoint jobs:\n%s' %
'\n'.join(' - %s' % url for url in job_urls))
cli_helpers.Comment(
'Posted a message with Pinpoint job URLs on the CL and sent it for '
'review. Opening the CL in a browser...')
_OpenBrowser(self._GetBranchIssueUrl())
# Hooray, you won! :-)
cli_helpers.Comment(
'Thank you, you have successfully updated the recording for %s. Please '
'wait for LGTM and land the created CL.' % self.story)
def Main(argv): def Main(argv):
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
...@@ -351,15 +578,27 @@ def Main(argv): ...@@ -351,15 +578,27 @@ def Main(argv):
'--binary', default=None, '--binary', default=None,
help='Path to the Chromium/Chrome binary relative to output directory. ' help='Path to the Chromium/Chrome binary relative to output directory. '
'Defaults to default Chrome browser installed if not specified.') 'Defaults to default Chrome browser installed if not specified.')
parser.add_argument(
'command', choices=[ subparsers = parser.add_subparsers(
'live', 'record', 'replay', 'upload', 'review', 'pinpoint'], title='Mode in which to run this script', dest='command')
help='Mode in which to run this script.') subparsers.add_parser(
'auto', help='interactive mode automating updating a recording')
subparsers.add_parser('live', help='run story on a live website')
subparsers.add_parser('record', help='record story from a live website')
subparsers.add_parser('replay', help='replay story from the recording')
subparsers.add_parser('upload', help='upload recording to the Google Storage')
subparsers.add_parser('review', help='create a CL with updated recording')
subparsers.add_parser(
'pinpoint', help='trigger Pinpoint jobs to test the recording')
args = parser.parse_args(argv) args = parser.parse_args(argv)
updater = WprUpdater(args) updater = WprUpdater(args)
if args.command =='live': if args.command == 'auto':
_EnsureEditor()
luci_auth.CheckLoggedIn()
updater.AutoRun()
elif args.command =='live':
updater.LiveRun() updater.LiveRun()
elif args.command == 'record': elif args.command == 'record':
updater.RecordWpr() updater.RecordWpr()
......
...@@ -35,6 +35,7 @@ class UpdateWprTest(unittest.TestCase): ...@@ -35,6 +35,7 @@ class UpdateWprTest(unittest.TestCase):
datetime.now.return_value.strftime.return_value = '<tstamp>' datetime.now.return_value.strftime.return_value = '<tstamp>'
mock.patch('tempfile.mkdtemp', return_value='/tmp/dir').start() mock.patch('tempfile.mkdtemp', return_value='/tmp/dir').start()
mock.patch('random.randint', return_value=1234).start()
mock.patch('core.cli_helpers.Fatal').start() mock.patch('core.cli_helpers.Fatal').start()
mock.patch('core.cli_helpers.Error').start() mock.patch('core.cli_helpers.Error').start()
mock.patch('core.cli_helpers.Step').start() mock.patch('core.cli_helpers.Step').start()
...@@ -47,6 +48,7 @@ class UpdateWprTest(unittest.TestCase): ...@@ -47,6 +48,7 @@ class UpdateWprTest(unittest.TestCase):
mock.patch(WPR_UPDATER + 'RECORD_WPR', '.../record_wpr').start() mock.patch(WPR_UPDATER + 'RECORD_WPR', '.../record_wpr').start()
mock.patch('os.path.join', lambda *parts: '/'.join(parts)).start() mock.patch('os.path.join', lambda *parts: '/'.join(parts)).start()
mock.patch('os.path.exists', return_value=True).start() mock.patch('os.path.exists', return_value=True).start()
mock.patch('time.sleep').start()
self.wpr_updater = update_wpr.WprUpdater(argparse.Namespace( self.wpr_updater = update_wpr.WprUpdater(argparse.Namespace(
story='<story>', device_id=None, repeat=1, binary=None, bug_id=None, story='<story>', device_id=None, repeat=1, binary=None, bug_id=None,
...@@ -58,13 +60,13 @@ class UpdateWprTest(unittest.TestCase): ...@@ -58,13 +60,13 @@ class UpdateWprTest(unittest.TestCase):
def testMain(self): def testMain(self):
wpr_updater_cls = mock.patch(WPR_UPDATER + 'WprUpdater').start() wpr_updater_cls = mock.patch(WPR_UPDATER + 'WprUpdater').start()
update_wpr.Main([ update_wpr.Main([
'live',
'-s', 'foo:bar:story:2019', '-s', 'foo:bar:story:2019',
'-d', 'H2345234FC33', '-d', 'H2345234FC33',
'--binary', '<binary>', '--binary', '<binary>',
'-b', '1234', '-b', '1234',
'-r', 'test_user1@chromium.org', '-r', 'test_user1@chromium.org',
'-r', 'test_user2@chromium.org', '-r', 'test_user2@chromium.org',
'live',
]) ])
self.assertListEqual(wpr_updater_cls.mock_calls, [ self.assertListEqual(wpr_updater_cls.mock_calls, [
mock.call(argparse.Namespace( mock.call(argparse.Namespace(
...@@ -90,6 +92,83 @@ class UpdateWprTest(unittest.TestCase): ...@@ -90,6 +92,83 @@ class UpdateWprTest(unittest.TestCase):
self.wpr_updater.Cleanup() self.wpr_updater.Cleanup()
rmtree.assert_called_once_with('/tmp/dir', ignore_errors=True) rmtree.assert_called_once_with('/tmp/dir', ignore_errors=True)
def testGetBranchName(self):
self._check_output.return_value = 'master'
self.assertEqual(update_wpr._GetBranchName(), 'master')
self._check_output.assert_called_once_with(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
def testCreateBranch(self):
self.wpr_updater._CreateBranch()
self._check_call.assert_called_once_with(
['git', 'new-branch', 'update-wpr--story--1234'])
def testSendCLForReview(self):
update_wpr._SendCLForReview('comment')
self._check_call.assert_called_once_with(
['git', 'cl', 'comments', '--publish', '--add-comment', 'comment'])
@mock.patch('os.dup')
@mock.patch('os.close')
@mock.patch('os.dup2')
@mock.patch('webbrowser.open')
def testOpenBrowser(self, webbrowser_open, os_dup2, os_close, os_dup):
del os_dup2, os_close, os_dup # unused
update_wpr._OpenBrowser('<url>')
webbrowser_open.assert_called_once_with('<url>')
def testAutoRun(self):
# Mock low-level methods tested above.
mock.patch(WPR_UPDATER + '_GetBranchName', return_value='HEAD').start()
mock.patch(
WPR_UPDATER + 'WprUpdater._GetBranchIssueUrl',
return_value='<issue-url>').start()
mock.patch(WPR_UPDATER + 'WprUpdater._CreateBranch').start()
send_cl_for_review = mock.patch(WPR_UPDATER + '_SendCLForReview').start()
open_browser = mock.patch(WPR_UPDATER + '_OpenBrowser').start()
# Mock high-level methods tested below.
live_run = mock.patch(WPR_UPDATER + 'WprUpdater.LiveRun').start()
record_wpr = mock.patch(WPR_UPDATER + 'WprUpdater.RecordWpr').start()
replay_wpr = mock.patch(WPR_UPDATER + 'WprUpdater.ReplayWpr').start()
upload_wpr = mock.patch(
WPR_UPDATER + 'WprUpdater.UploadWpr', return_value=True).start()
upload_cl = mock.patch(
WPR_UPDATER + 'WprUpdater.UploadCL', return_value=0).start()
start_pinpoint_jobs = mock.patch(
WPR_UPDATER + 'WprUpdater.StartPinpointJobs',
return_value=(['<url1>', '<url2>', '<url3>'], [])).start()
# Mock user interaction.
mock.patch('core.cli_helpers.Ask', side_effect=[
True, # Should script create a new branch automatically?
'continue', # Should I continue with recording, ...?
'continue', # Should I record and replay again, ...?
]).start()
self.wpr_updater.AutoRun()
# Run once to make sure story works.
live_run.assert_called_once_with()
# Run again to create a recording.
record_wpr.assert_called_once_with()
# Replay to verify the recording.
replay_wpr.assert_called_once_with()
# Upload the recording.
upload_wpr.assert_called_once_with()
# Upload the CL.
upload_cl.assert_called_once_with(short_description=False)
# Start pinpoint jobs to verify recording works on the bots.
start_pinpoint_jobs.assert_called_once_with(None)
# Send CL for review with a comment listing triggered Pinpoint jobs.
send_cl_for_review.assert_called_once_with(
'Started the following Pinpoint jobs:\n'
' - <url1>\n'
' - <url2>\n'
' - <url3>')
# Open the CL in browser,
open_browser.assert_called_once_with('<issue-url>')
def testLiveRun(self): def testLiveRun(self):
run_benchmark = mock.patch( run_benchmark = mock.patch(
WPR_UPDATER + 'WprUpdater._RunSystemHealthMemoryBenchmark', WPR_UPDATER + 'WprUpdater._RunSystemHealthMemoryBenchmark',
......
...@@ -133,6 +133,15 @@ def Ask(question, answers=None, default=None): ...@@ -133,6 +133,15 @@ def Ask(question, answers=None, default=None):
', '.join(choices[:-1]), choices[-1])) ', '.join(choices[:-1]), choices[-1]))
def Prompt(question, accept_empty=False):
while True:
print(Colored(question, color='cyan'))
answer = raw_input().strip()
if answer or accept_empty:
return answer
Error('Please enter non-empty answer')
def CheckLog(command, log_path, env=None): def CheckLog(command, log_path, env=None):
"""Executes a command and writes its stdout to a specified log file. """Executes a command and writes its stdout to a specified log file.
......
...@@ -152,6 +152,16 @@ class CLIHelpersTest(unittest.TestCase): ...@@ -152,6 +152,16 @@ class CLIHelpersTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
cli_helpers.Run('cmd with args') cli_helpers.Run('cmd with args')
@mock.patch('__builtin__.print')
@mock.patch('__builtin__.raw_input')
def testPrompt(self, raw_input_mock, print_mock):
raw_input_mock.side_effect = ['', '42']
self.assertEqual(cli_helpers.Prompt(
'What is the ultimate meaning of life, universe and everything?'), '42')
self.assertEqual(raw_input_mock.call_count, 2)
self.assertEqual(print_mock.call_count, 3)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment