summaryrefslogtreecommitdiff
path: root/systrace/catapult/common/py_utils/py_utils/webpagereplay_go_server.py
blob: 95e44954c7324741f93e242da75e40ef441ea57a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# Copyright 2017 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Start and stop Web Page Replay."""

import logging
import os
import re
import signal
import subprocess
import sys
import tempfile
import urllib

import py_utils
from py_utils import atexit_with_log
from py_utils import binary_manager

_WPR_DIR = os.path.abspath(os.path.join(
    py_utils.GetCatapultDir(), 'web_page_replay_go'))

TELEMETRY_PROJECT_CONFIG = os.path.join(
    py_utils.GetCatapultDir(), 'telemetry', 'telemetry',
    'binary_dependencies.json')

CHROME_BINARY_CONFIG = os.path.join(
    py_utils.GetCatapultDir(), 'common', 'py_utils', 'py_utils',
    'chrome_binaries.json')

RECORD = '--record'
INJECT_SCRIPTS = '--inject_scripts='
USE_LOCAL_WPR = '--use-local-wpr'
DISABLE_FUZZY_URL_MATCHING = '--disable-fuzzy-url-matching'

class ReplayError(Exception):
  """Catch-all exception for the module."""
  pass


class ReplayNotFoundError(ReplayError):
  def __init__(self, label, path):
    """
    Create a ReplayNotFoundError instance.

    Args:

      label: A string of label of this error.
      path: A string of the path in this error.

    """
    super(ReplayNotFoundError, self).__init__()
    self.args = (label, path)

  def __str__(self):
    label, path = self.args
    return 'Path does not exist for %s: %s' % (label, path)


class ReplayNotStartedError(ReplayError):
  pass


class ReplayServer(object):
  """Start and Stop Web Page Replay.

  Web Page Replay is a proxy that can record and "replay" web pages with
  simulated network characteristics -- without having to edit the pages
  by hand. With WPR, tests can use "real" web content, and catch
  performance issues that may result from introducing network delays and
  bandwidth throttling.

  This class could be used as a context manager.

  Example:
     with ReplayServer(archive_path):
       self.NavigateToURL(start_url)
       self.WaitUntil(...)
  """

  _go_binary_path = None

  def __init__(self, archive_path, replay_host, http_port, https_port,
               replay_options, binary_downloader=None):
    """Initialize ReplayServer.

    Args:
      archive_path: a path to a specific WPR archive.
      replay_host: the hostname to serve traffic.
      http_port: an integer port on which to serve HTTP traffic. May be zero
          to let the OS choose an available port.
      https_port: an integer port on which to serve HTTPS traffic. May be zero
          to let the OS choose an available port.
      replay_options: an iterable of options strings to forward to replay.py.
      binary_downloader: a function to be used to fetch binary. May be None to
          use py_utils.binary_manager.FetchPath as default downloader.
    """
    self.archive_path = archive_path
    self._replay_host = replay_host
    self._started_ports = {}  # a dict such as {'http': 80, 'https': 443}

    # A temporary path for storing stdout & stderr of the webpagereplay
    # subprocess.
    self._temp_log_file_path = None

    self._downloader = binary_downloader
    self._replay_options = replay_options
    self._cmd_line = self._GetCommandLine(
        self._GetGoBinaryPath(replay_options), http_port, https_port,
        replay_options, archive_path)

    if RECORD in replay_options or 'record' in replay_options:
      self._AssertPathExists('archive directory',
                             os.path.dirname(self.archive_path))
    elif not os.path.exists(self.archive_path):
      self._AssertPathExists('archive file', self.archive_path)

    self.replay_process = None

  def _GetDownloader(self):
    """Gets the downloader used to download wpr_go binary from GCS."""
    if ReplayServer._go_binary_path:
      # If the _go_binary_path was already set, then no need to use downloader
      # to download via binary_manager.
      self._downloader = None
    elif not self._downloader:
      configs = [CHROME_BINARY_CONFIG, TELEMETRY_PROJECT_CONFIG]
      self._downloader = binary_manager.BinaryManager(configs).FetchPath
    return self._downloader

  def _GetGoBinaryPath(self, replay_options):
    """Gets the _go_binary_path if it already set, or downloads it."""
    if USE_LOCAL_WPR in replay_options:
      # Build WPR
      go_folder = os.path.join(_WPR_DIR, 'src')
      cur_cwd = os.getcwd()
      os.chdir(go_folder)
      try:
        print subprocess.check_output(['go', 'build', os.path.join(go_folder, 'wpr.go')])
      except subprocess.CalledProcessError:
        exit(1)
      os.chdir(cur_cwd)

      return os.path.join(go_folder, 'wpr')

    if not ReplayServer._go_binary_path:
      downloader = self._GetDownloader()
      if not downloader:
        raise RuntimeError('downloader should not be None '
                           'while _go_binary_path is None')
      ReplayServer._go_binary_path = downloader(
          'wpr_go', py_utils.GetHostOsName(), py_utils.GetHostArchName())
    return ReplayServer._go_binary_path

  @classmethod
  def SetGoBinaryPath(cls, go_binary_path):
    """Overrides the _go_binary_path.

    This allows the server to use WPRGO files retrieved from somewhere
    other than GCS via binary_manager, such as test isolation.

    For chromium project to use WPR, it is encourage to use test isolation,
    and therefore should call SetGoBinaryPath to set _go_binary_path.

    For Catapult/Telemetry project, the tradition is to download wpr_go
    binary via binary_manager. So do not call SetGoBinaryPath.
    """
    if not os.path.exists(go_binary_path):
      raise ValueError('SetGoBinaryPath could not set {} as it does not exist'
                       .format(go_binary_path))
    cls._go_binary_path = go_binary_path

  @property
  def http_port(self):
    return self._started_ports['http']

  @property
  def https_port(self):
    return self._started_ports['https']

  @staticmethod
  def _GetCommandLine(go_binary_path, http_port, https_port,
                      options, archive_path):
    """Set WPR command-line arguments. Can be overridden if needed.

    Keyword arguments:

    * go_binary_path: A string of the path to the wpr.go binary.
    * http_port: A decimal of the port that handles http requests.
    * https_port: A decimal of the port that handles https requests.
    * options: A list of options, such as '--record',
        '--inject_scripts', etc.
    * archive_path: A string of the path to the archive file.

    """
    bad_options = []
    for option in options:
      if option not in [RECORD, INJECT_SCRIPTS,
                        USE_LOCAL_WPR, DISABLE_FUZZY_URL_MATCHING]:
        bad_options.append(option)
    if len(bad_options) > 0:
      raise ValueError("Invalid replay options %s" % bad_options)

    cmd_line = [go_binary_path]
    if RECORD in options:
      cmd_line.append('record')
    else:
      cmd_line.append('replay')
    if DISABLE_FUZZY_URL_MATCHING in options:
      cmd_line.append('--disable_fuzzy_url_matching')
    key_file = os.path.join(_WPR_DIR, 'wpr_key.pem')
    cert_file = os.path.join(_WPR_DIR, 'wpr_cert.pem')
    inject_script = os.path.join(_WPR_DIR, 'deterministic.js')
    cmd_line.extend([
        '--http_port=%s' % http_port,
        '--https_port=%s' % https_port,
        '--https_key_file=%s' % key_file,
        '--https_cert_file=%s' % cert_file])
    if INJECT_SCRIPTS in options:
      cmd_line.append(INJECT_SCRIPTS)
    else:
      cmd_line.append('--inject_scripts=%s' % inject_script)
    cmd_line.append(archive_path)
    return cmd_line

  def _AssertPathExists(self, label, path):
    if not os.path.exists(path):
      raise ReplayNotFoundError(label, path)

  def _OpenLogFile(self):
    """Opens the log file for writing."""
    log_dir = os.path.dirname(self._temp_log_file_path)
    if not os.path.isdir(log_dir):
      os.makedirs(log_dir)
    return open(self._temp_log_file_path, 'w')

  def _LogLines(self):
    """Yields any log lines that have been writtent to disk."""
    if (not self._temp_log_file_path or
        not os.path.isfile(self._temp_log_file_path)):
      yield '(N/A)'
      return
    with open(self._temp_log_file_path) as f:
      for line in f:
        yield line

  def _IsStarted(self):
    """Returns true if the server is up and running."""
    if not self._IsReplayProcessStarted():
      return False

    def HasIncompleteStartedPorts():
      return ('http' not in self._started_ports or
              'https' not in self._started_ports)

    if HasIncompleteStartedPorts():
      self._started_ports = self._ParseLogFilePorts(self._LogLines())
    if HasIncompleteStartedPorts():
      return False

    try:
      # HTTPS may require SNI (which urllib does not speak), so only check
      # that HTTP responds.
      return self._UrlOpen('web-page-replay-generate-200').getcode() == 200
    except IOError:
      return False

  @staticmethod
  def _ParseLogFilePorts(log_lines):
    """Returns the ports on which replay listens as reported in its log file.

    Only matches HTTP, HTTPS, and DNS. One call may return only some
    of the ports depending on what has been written to the log file.

    Example log lines:
      2014-09-03 17:04:27,978 Starting server on http://:51673
      2014-09-03 17:04:27,978 Starting server on https://:35270

    Returns:
      a dict with ports available in log_lines. For example,
         {}  # no ports found
         {'http': 1234, 'https': 2345, 'dns': 3456}
    """
    ports = {}
    port_re = re.compile(
        r'.*Starting server on '
        r'(?P<protocol>http|https)://'
        r'(?P<host>[^:]*):'
        r'(?P<port>\d+)')
    for line in log_lines:
      m = port_re.match(line.strip())
      if m:
        protocol = m.group('protocol').lower()
        ports[protocol] = int(m.group('port'))
    return ports

  def StartServer(self):
    """Start Web Page Replay and verify that it started.

    Returns:
      A dictionary mapping the keys 'http', 'https', and (if used) 'dns'
      to the respective ports of the replay server.
    Raises:
      ReplayNotStartedError: if Replay start-up fails.
    """
    is_posix = sys.platform.startswith('linux') or sys.platform == 'darwin'
    logging.info('Starting Web-Page-Replay: %s', self._cmd_line)
    self._CreateTempLogFilePath()
    with self._OpenLogFile() as log_fh:
      self.replay_process = subprocess.Popen(
          self._cmd_line, stdout=log_fh, stderr=subprocess.STDOUT,
          preexec_fn=(_ResetInterruptHandler if is_posix else None))
    try:
      # TODO(crbug.com/805418): consider changing this to wait with I/O timeout.
      # The 120s timeout is based on past failures (e.g: crbug.com/812639).
      py_utils.WaitFor(self._IsStarted, timeout=120)
      logging.info('WPR ports: %s', self._started_ports)
      atexit_with_log.Register(self.StopServer)
      return dict(self._started_ports)
    except Exception:
      self.StopServer(logging.ERROR)
      raise ReplayNotStartedError('Web Page Replay failed to start.')

  def _IsReplayProcessStarted(self):
    if not self.replay_process:
      return False
    return self.replay_process and self.replay_process.poll() is None

  def StopServer(self, log_level=logging.DEBUG):
    """Stop Web Page Replay.

    This also attempts to return stdout/stderr logs of wpr process if there is
    any. If there is none, '(N/A)' string is returned (see _LogLines()
    implementation).
    """
    if self._IsReplayProcessStarted():
      self._StopReplayProcess()
    self._CleanUpTempLogFilePath(log_level)
    self._started_ports = {}

  def _StopReplayProcess(self):
    if not self.replay_process:
      return
    logging.debug('Trying to stop Web-Page-Replay gracefully')
    try:
      if self._started_ports:
        self._UrlOpen('web-page-replay-command-exit').close()
    except IOError:
      # IOError is possible because the server might exit without response.
      pass
    try:
      py_utils.WaitFor(lambda: self.replay_process.poll() is not None, 10)
    except py_utils.TimeoutException:
      try:
        # Use a SIGINT so that it can do graceful cleanup.
        self.replay_process.send_signal(signal.SIGINT)
      except Exception:  # pylint: disable=broad-except
        # On Windows, we are left with no other option than terminate().
        is_primary_nameserver_changed_by_replay = (
            self._replay_host == '127.0.0.1')
        if is_primary_nameserver_changed_by_replay:
          # Replay changes the DNS nameserver configuration so that DNS
          # requests are resolved by replay's own DNS server. It resolves
          # all DNS requests to it own IP address to it can server the
          # HTTP and HTTPS requests.
          # If the replay host is not '127.0.0.1', then replay skips the
          # nameserver change because it assumes a different mechanism
          # will be used to route DNS requests to replay's DNS server.
          logging.warning(
              'Unable to stop Web-Page-Replay gracefully.\n'
              'Replay changed the DNS nameserver configuration to make replay '
              'the primary nameserver. That might not be restored!')
        self.replay_process.terminate()
      self.replay_process.communicate()
    finally:
      self.replay_process = None

  def _CreateTempLogFilePath(self):
    assert self._temp_log_file_path is None
    handle, self._temp_log_file_path = tempfile.mkstemp()
    os.close(handle)

  def _CleanUpTempLogFilePath(self, log_level):
    if not self._temp_log_file_path:
      return ''
    if logging.getLogger('').isEnabledFor(log_level) or USE_LOCAL_WPR in self._replay_options:
      with open(self._temp_log_file_path, 'r') as f:
        wpr_log_output = f.read()
      output = ('************************** WPR LOG *****************************\n' +
                '\n'.join(wpr_log_output.split('\n')) +
                '************************** END OF WPR LOG **********************')
      if logging.getLogger('').isEnabledFor(log_level):
        logging.log(log_level, output)
      else:
        print output

    os.remove(self._temp_log_file_path)
    self._temp_log_file_path = None

  def __enter__(self):
    """Add support for with-statement."""
    self.StartServer()
    return self

  def __exit__(self, unused_exc_type, unused_exc_val, unused_exc_tb):
    """Add support for with-statement."""
    self.StopServer()

  def _UrlOpen(self, url_path, protocol='http'):
    """Open a Replay URL.

    For matching requests in the archive, Replay relies on the "Host:" header.
    For Replay command URLs, the "Host:" header is not needed.

    Args:
      url_path: WPR server request path.
      protocol: 'http' or 'https'
    Returns:
      a file-like object from urllib.urlopen
    """
    url = '%s://%s:%s/%s' % (
        protocol, self._replay_host, self._started_ports[protocol], url_path)
    return urllib.urlopen(url, proxies={})

def _ResetInterruptHandler():
  """Reset the interrupt handler back to the default.

  The replay process is stopped gracefully by making an HTTP request
  ('web-page-replay-command-exit'). The graceful exit is important for
  restoring the DNS configuration. If the HTTP request fails, the fallback
  is to send SIGINT to the process.

  On posix system, running this function before starting replay fixes a
  bug that shows up when Telemetry is run as a background command from a
  script. https://crbug.com/254572.

  Background: Signal masks on Linux are inherited from parent
  processes. If anything invoking us accidentally masks SIGINT
  (e.g. by putting a process in the background from a shell script),
  sending a SIGINT to the child will fail to terminate it.
  """
  signal.signal(signal.SIGINT, signal.SIG_DFL)