ninjalog_uploader.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. #!/usr/bin/env python3
  2. # Copyright 2018 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """
  6. This is script to upload ninja_log from googler.
  7. Server side implementation is in
  8. https://cs.chromium.org/chromium/infra/go/src/infra/appengine/chromium_build_stats/
  9. Uploaded ninjalog is stored in BigQuery table having following schema.
  10. https://cs.chromium.org/chromium/infra/go/src/infra/appengine/chromium_build_stats/ninjaproto/ninjalog.proto
  11. The log will be used to analyze user side build performance.
  12. See also the privacy review. http://eldar/assessments/656778450
  13. """
  14. import argparse
  15. import gzip
  16. import io
  17. import json
  18. import logging
  19. import multiprocessing
  20. import os
  21. import platform
  22. import subprocess
  23. import sys
  24. import time
  25. import urllib.request
  26. # These build configs affect build performance.
  27. ALLOWLISTED_CONFIGS = (
  28. "symbol_level",
  29. "use_goma",
  30. "use_remoteexec",
  31. "use_siso",
  32. "is_debug",
  33. "is_component_build",
  34. "enable_nacl",
  35. "host_os",
  36. "host_cpu",
  37. "target_os",
  38. "target_cpu",
  39. "blink_symbol_level",
  40. "is_java_debug",
  41. "treat_warnings_as_errors",
  42. "disable_android_lint",
  43. "use_errorprone_java_compiler",
  44. "incremental_install",
  45. "android_static_analysis",
  46. )
  47. def IsGoogler():
  48. """Check whether this user is Googler or not."""
  49. p = subprocess.run(
  50. "cipd auth-info",
  51. stdout=subprocess.PIPE,
  52. stderr=subprocess.PIPE,
  53. text=True,
  54. shell=True,
  55. )
  56. if p.returncode != 0:
  57. return False
  58. lines = p.stdout.splitlines()
  59. if len(lines) == 0:
  60. return False
  61. l = lines[0]
  62. # |l| will be like 'Logged in as <user>@google.com.' for googler using
  63. # reclient.
  64. return l.startswith("Logged in as ") and l.endswith("@google.com.")
  65. def ParseGNArgs(gn_args):
  66. """Parse gn_args as json and return config dictionary."""
  67. configs = json.loads(gn_args)
  68. build_configs = {}
  69. for config in configs:
  70. key = config["name"]
  71. if key not in ALLOWLISTED_CONFIGS:
  72. continue
  73. if "current" in config:
  74. build_configs[key] = config["current"]["value"]
  75. else:
  76. build_configs[key] = config["default"]["value"]
  77. return build_configs
  78. def GetBuildTargetFromCommandLine(cmdline):
  79. """Get build targets from commandline."""
  80. # Skip argv0, argv1: ['/path/to/python3', '/path/to/depot_tools/ninja.py']
  81. idx = 2
  82. # Skipping all args that involve these flags, and taking all remaining args
  83. # as targets.
  84. onearg_flags = ("-C", "-d", "-f", "-j", "-k", "-l", "-p", "-t", "-w")
  85. zeroarg_flags = ("--version", "-n", "-v")
  86. targets = []
  87. while idx < len(cmdline):
  88. arg = cmdline[idx]
  89. if arg in onearg_flags:
  90. idx += 2
  91. continue
  92. if arg[:2] in onearg_flags or arg in zeroarg_flags:
  93. idx += 1
  94. continue
  95. # A target doesn't start with '-'.
  96. if arg.startswith("-"):
  97. idx += 1
  98. continue
  99. # Avoid uploading absolute paths accidentally. e.g. b/270907050
  100. if os.path.isabs(arg):
  101. idx += 1
  102. continue
  103. targets.append(arg)
  104. idx += 1
  105. return targets
  106. def GetJflag(cmdline):
  107. """Parse cmdline to get flag value for -j"""
  108. for i in range(len(cmdline)):
  109. if (cmdline[i] == "-j" and i + 1 < len(cmdline)
  110. and cmdline[i + 1].isdigit()):
  111. return int(cmdline[i + 1])
  112. if cmdline[i].startswith("-j") and cmdline[i][len("-j"):].isdigit():
  113. return int(cmdline[i][len("-j"):])
  114. def GetMetadata(cmdline, ninjalog):
  115. """Get metadata for uploaded ninjalog.
  116. Returned metadata has schema defined in
  117. https://cs.chromium.org?q="type+Metadata+struct+%7B"+file:%5Einfra/go/src/infra/appengine/chromium_build_stats/ninjalog/
  118. TODO(tikuta): Collect GOMA_* env var.
  119. """
  120. build_dir = os.path.dirname(ninjalog)
  121. build_configs = {}
  122. try:
  123. args = ["gn", "args", build_dir, "--list", "--short", "--json"]
  124. if sys.platform == "win32":
  125. # gn in PATH is bat file in windows environment (except cygwin).
  126. args = ["cmd", "/c"] + args
  127. gn_args = subprocess.check_output(args)
  128. build_configs = ParseGNArgs(gn_args)
  129. except subprocess.CalledProcessError as e:
  130. logging.error("Failed to call gn %s", e)
  131. build_configs = {}
  132. # Stringify config.
  133. for k in build_configs:
  134. build_configs[k] = str(build_configs[k])
  135. metadata = {
  136. "platform": platform.system(),
  137. "cpu_core": multiprocessing.cpu_count(),
  138. "build_configs": build_configs,
  139. "targets": GetBuildTargetFromCommandLine(cmdline),
  140. }
  141. jflag = GetJflag(cmdline)
  142. if jflag is not None:
  143. metadata["jobs"] = jflag
  144. return metadata
  145. def GetNinjalog(cmdline):
  146. """GetNinjalog returns the path to ninjalog from cmdline."""
  147. # ninjalog is in current working directory by default.
  148. ninjalog_dir = "."
  149. i = 0
  150. while i < len(cmdline):
  151. cmd = cmdline[i]
  152. i += 1
  153. if cmd == "-C" and i < len(cmdline):
  154. ninjalog_dir = cmdline[i]
  155. i += 1
  156. continue
  157. if cmd.startswith("-C") and len(cmd) > len("-C"):
  158. ninjalog_dir = cmd[len("-C"):]
  159. return os.path.join(ninjalog_dir, ".ninja_log")
  160. def main():
  161. parser = argparse.ArgumentParser()
  162. parser.add_argument(
  163. "--server",
  164. default="chromium-build-stats.appspot.com",
  165. help="server to upload ninjalog file.",
  166. )
  167. parser.add_argument("--ninjalog", help="ninjalog file to upload.")
  168. parser.add_argument("--verbose",
  169. action="store_true",
  170. help="Enable verbose logging.")
  171. parser.add_argument(
  172. "--cmdline",
  173. required=True,
  174. nargs=argparse.REMAINDER,
  175. help="command line args passed to ninja.",
  176. )
  177. args = parser.parse_args()
  178. if args.verbose:
  179. logging.basicConfig(level=logging.INFO)
  180. else:
  181. # Disable logging.
  182. logging.disable(logging.CRITICAL)
  183. if not IsGoogler():
  184. return 0
  185. ninjalog = args.ninjalog or GetNinjalog(args.cmdline)
  186. if not os.path.isfile(ninjalog):
  187. logging.warning("ninjalog is not found in %s", ninjalog)
  188. return 1
  189. # We assume that each ninja invocation interval takes at least 2 seconds.
  190. # This is not to have duplicate entry in server when current build is no-op.
  191. if os.stat(ninjalog).st_mtime < time.time() - 2:
  192. logging.info("ninjalog is not updated recently %s", ninjalog)
  193. return 0
  194. output = io.BytesIO()
  195. with open(ninjalog) as f:
  196. with gzip.GzipFile(fileobj=output, mode="wb") as g:
  197. g.write(f.read().encode())
  198. g.write(b"# end of ninja log\n")
  199. metadata = GetMetadata(args.cmdline, ninjalog)
  200. logging.info("send metadata: %s", json.dumps(metadata))
  201. g.write(json.dumps(metadata).encode())
  202. resp = urllib.request.urlopen(
  203. urllib.request.Request(
  204. "https://" + args.server + "/upload_ninja_log/",
  205. data=output.getvalue(),
  206. headers={"Content-Encoding": "gzip"},
  207. ))
  208. if resp.status != 200:
  209. logging.warning("unexpected status code for response: %s", resp.status)
  210. return 1
  211. logging.info("response header: %s", resp.headers)
  212. logging.info("response content: %s", resp.read())
  213. return 0
  214. if __name__ == "__main__":
  215. sys.exit(main())