ninjalog_uploader.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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 getpass
  16. import contextlib
  17. import gzip
  18. import http
  19. import io
  20. import json
  21. import logging
  22. import multiprocessing
  23. import os
  24. import pathlib
  25. import platform
  26. import re
  27. import subprocess
  28. import sys
  29. import time
  30. import urllib.request
  31. import build_telemetry
  32. import gclient_utils
  33. # Configs that should not be uploaded as is.
  34. SENSITIVE_CONFIGS = (
  35. "google_api_key",
  36. "google_default_client_id",
  37. "google_default_client_secret",
  38. "ios_credential_provider_extension_api_key",
  39. "ios_credential_provider_extension_client_id",
  40. "ios_encryption_export_compliance_code",
  41. "ios_google_test_oauth_client_id",
  42. "ios_google_test_oauth_client_secret",
  43. )
  44. def ParseGNArgs(gn_args):
  45. """Parse gn_args as json and return config dictionary."""
  46. configs = json.loads(gn_args)
  47. build_configs = {}
  48. explicit_keys = []
  49. user = getpass.getuser()
  50. for config in configs:
  51. key = config["name"]
  52. if "current" in config:
  53. value = config["current"]["value"]
  54. # Record configs specified in args.gn as explicit configs.
  55. if config["current"]["file"] != "//.gn":
  56. explicit_keys.append(key)
  57. else:
  58. value = config["default"]["value"]
  59. value = value.strip('"')
  60. if key in SENSITIVE_CONFIGS and value:
  61. value = '<omitted>'
  62. # Do not upload username.
  63. if os.path.isabs(value):
  64. value = os.path.join(*[
  65. p if p != user else "$USER" for p in pathlib.Path(value).parts
  66. ])
  67. build_configs[key] = value
  68. return build_configs, explicit_keys
  69. def GetBuildTargetFromCommandLine(cmdline):
  70. """Get build targets from commandline."""
  71. # Skip argv0, argv1: ['/path/to/python3', '/path/to/depot_tools/ninja.py']
  72. idx = 2
  73. # Skipping all args that involve these flags, and taking all remaining args
  74. # as targets.
  75. onearg_flags = ("-C", "-d", "-f", "-j", "-k", "-l", "-p", "-t", "-w")
  76. zeroarg_flags = ("--version", "-n", "-v")
  77. targets = []
  78. while idx < len(cmdline):
  79. arg = cmdline[idx]
  80. if arg in onearg_flags:
  81. idx += 2
  82. continue
  83. if arg[:2] in onearg_flags or arg in zeroarg_flags:
  84. idx += 1
  85. continue
  86. # A target doesn't start with '-'.
  87. if arg.startswith("-"):
  88. idx += 1
  89. continue
  90. # Avoid uploading absolute paths accidentally. e.g. b/270907050
  91. if os.path.isabs(arg):
  92. idx += 1
  93. continue
  94. targets.append(arg)
  95. idx += 1
  96. return targets
  97. def GetJflag(cmdline):
  98. """Parse cmdline to get flag value for -j"""
  99. for i in range(len(cmdline)):
  100. if (cmdline[i] == "-j" and i + 1 < len(cmdline)
  101. and cmdline[i + 1].isdigit()):
  102. return int(cmdline[i + 1])
  103. if cmdline[i].startswith("-j") and cmdline[i][len("-j"):].isdigit():
  104. return int(cmdline[i][len("-j"):])
  105. def GetMetadata(cmdline, ninjalog, exit_code, build_duration, user):
  106. """Get metadata for uploaded ninjalog.
  107. Returned metadata has schema defined in
  108. https://cs.chromium.org?q="type+Metadata+struct+%7B"+file:%5Einfra/go/src/infra/appengine/chromium_build_stats/ninjalog/
  109. """
  110. build_dir = os.path.dirname(ninjalog)
  111. build_configs = {}
  112. explicit_keys = []
  113. try:
  114. args = ["gn", "args", build_dir, "--list", "--json"]
  115. if sys.platform == "win32":
  116. # gn in PATH is bat file in windows environment (except cygwin).
  117. args = ["cmd", "/c"] + args
  118. gn_args = subprocess.check_output(args)
  119. build_configs, explicit_keys = ParseGNArgs(gn_args)
  120. except subprocess.CalledProcessError as e:
  121. logging.error("Failed to call gn %s", e)
  122. build_configs = {}
  123. # Stringify config.
  124. for k in build_configs:
  125. build_configs[k] = str(build_configs[k])
  126. metadata = {
  127. "user": user,
  128. "exit_code": exit_code,
  129. "build_duration_sec": build_duration,
  130. "platform": platform.system(),
  131. "cpu_core": multiprocessing.cpu_count(),
  132. "is_cog": gclient_utils.IsEnvCog(),
  133. "is_cloudtop": False,
  134. "build_configs": build_configs,
  135. "explicit_build_config_keys": explicit_keys,
  136. "targets": GetBuildTargetFromCommandLine(cmdline),
  137. }
  138. metadata.update(GetGCEMetadata())
  139. invocation_id = os.environ.get("AUTONINJA_BUILD_ID")
  140. if invocation_id:
  141. metadata['invocation_id'] = invocation_id
  142. jflag = GetJflag(cmdline)
  143. if jflag is not None:
  144. metadata["jobs"] = jflag
  145. with contextlib.suppress(FileNotFoundError):
  146. with open(os.path.join(build_dir, ".siso_metadata.json"), "r") as f:
  147. metadata["siso_metadata"] = json.load(f)
  148. return metadata
  149. def GetGCEMetadata():
  150. gce = _getGCEInfo()
  151. if not gce:
  152. return {}
  153. md = {}
  154. if "cloudtop" in gce.get("project", {}).get("projectId", ""):
  155. md["is_cloudtop"] = True
  156. match = re.search(r"machineTypes/([^/]+)",
  157. gce.get("instance", {}).get("machineType", ""))
  158. if match:
  159. md["gce_machine_type"] = match.group(1)
  160. return md
  161. def _getGCEInfo():
  162. url = "http://metadata.google.internal/computeMetadata/v1/?recursive=true"
  163. request = urllib.request.Request(url, headers={"Metadata-Flavor": "Google"})
  164. try:
  165. response = urllib.request.urlopen(request)
  166. meta = json.load(response)
  167. except Exception as e:
  168. # Only GCE machines can access to the metadata server.
  169. logging.warning(e)
  170. return
  171. return meta
  172. def GetNinjalog(cmdline):
  173. """GetNinjalog returns the path to ninjalog from cmdline."""
  174. # ninjalog is in current working directory by default.
  175. ninjalog_dir = "."
  176. i = 0
  177. while i < len(cmdline):
  178. cmd = cmdline[i]
  179. i += 1
  180. if cmd == "-C" and i < len(cmdline):
  181. ninjalog_dir = cmdline[i]
  182. i += 1
  183. continue
  184. if cmd.startswith("-C") and len(cmd) > len("-C"):
  185. ninjalog_dir = cmd[len("-C"):]
  186. return os.path.join(ninjalog_dir, ".ninja_log")
  187. def UploadNinjaLog(server, ninjalog, metadata):
  188. output = io.BytesIO()
  189. with open(ninjalog) as f:
  190. with gzip.GzipFile(fileobj=output, mode="wb") as g:
  191. g.write(f.read().encode())
  192. g.write(b"# end of ninja log\n")
  193. logging.info("send metadata: %s", json.dumps(metadata))
  194. g.write(json.dumps(metadata).encode())
  195. status = None
  196. err_msg = ""
  197. try:
  198. resp = urllib.request.urlopen(
  199. urllib.request.Request(
  200. "https://" + server + "/upload_ninja_log/",
  201. data=output.getvalue(),
  202. headers={"Content-Encoding": "gzip"},
  203. ))
  204. status = resp.status
  205. logging.info("response header: %s", resp.headers)
  206. logging.info("response content: %s", resp.read())
  207. except urllib.error.HTTPError as e:
  208. status = e.status
  209. err_msg = e.msg
  210. if status != http.HTTPStatus.OK:
  211. logging.warning(
  212. "unexpected status code for response: status: %s, msg: %s", status,
  213. err_msg)
  214. return 1
  215. return 0
  216. def main():
  217. parser = argparse.ArgumentParser()
  218. parser.add_argument(
  219. "--server",
  220. default="chromium-build-stats.appspot.com",
  221. help="server to upload ninjalog file.",
  222. )
  223. parser.add_argument("--ninjalog", help="ninjalog file to upload.")
  224. parser.add_argument("--verbose",
  225. action="store_true",
  226. help="Enable verbose logging.")
  227. parser.add_argument("--exit_code",
  228. type=int,
  229. help="exit code of the ninja command.")
  230. parser.add_argument("--build_duration",
  231. type=int,
  232. help="total duration spent on autoninja (secounds)")
  233. parser.add_argument(
  234. "--cmdline",
  235. required=True,
  236. nargs=argparse.REMAINDER,
  237. help="command line args passed to ninja.",
  238. )
  239. args = parser.parse_args()
  240. if args.verbose:
  241. logging.basicConfig(level=logging.INFO)
  242. else:
  243. # Disable logging.
  244. logging.disable(logging.CRITICAL)
  245. cfg = build_telemetry.load_config()
  246. if not cfg.is_googler:
  247. logging.warning("Not Googler. Only Googlers can upload ninjalog.")
  248. return 1
  249. ninjalog = args.ninjalog or GetNinjalog(args.cmdline)
  250. if not os.path.isfile(ninjalog):
  251. logging.warning("ninjalog is not found in %s", ninjalog)
  252. return 1
  253. # To avoid uploading duplicated ninjalog entries,
  254. # record the mtime of ninjalog that is uploaded.
  255. # If the recorded timestamp is older than the mtime of ninjalog,
  256. # itt needs to be uploaded.
  257. ninjalog_mtime = os.stat(ninjalog).st_mtime
  258. last_upload_file = pathlib.Path(ninjalog + '.last_upload')
  259. if last_upload_file.exists() and ninjalog_mtime <= last_upload_file.stat(
  260. ).st_mtime:
  261. logging.info("ninjalog is already uploaded.")
  262. return 0
  263. metadata = GetMetadata(args.cmdline, ninjalog, args.exit_code,
  264. args.build_duration, cfg.user)
  265. exit_code = UploadNinjaLog(args.server, ninjalog, metadata)
  266. if exit_code == 0:
  267. last_upload_file.touch()
  268. return exit_code
  269. if __name__ == "__main__":
  270. sys.exit(main())