blob: 12b1bbef457e141ab44716a0ebd851845dbaad4f [file] [log] [blame]
# Copyright 2019 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Module to use goma and archive goma logs."""
import datetime
import getpass
import glob
import json
import logging
import os
from pathlib import Path
import shlex
import shutil
import tempfile
from typing import List, NamedTuple, Optional, Union
from chromite.cbuildbot import goma_util
from chromite.lib import chroot_lib
from chromite.lib import cros_build_lib
from chromite.lib import osutils
from chromite.utils import hostname_util
class GomaApproach(NamedTuple):
"""GomaApproach server and rpc details."""
rpc_extra_params: str
server_host: str
arbitrary_toolchain_support: bool
class ArchivedFiles(NamedTuple):
"""Goma files to be archived."""
stats_file: Optional[str]
counterz_file: Optional[str]
log_files: List[str]
class SpecifiedFileMissingError(Exception):
"""Error occurred when running LogsArchiver."""
# TODO(crbug.com/1035114) Refactor.
class Goma:
"""Interface to use goma on bots."""
# Default environment variables to use goma.
_DEFAULT_ENV_VARS = {
# Set MAX_COMPILER_DISABLED_TASKS to let goma enter Burst mode, if
# there are too many local fallback failures. In the Burst mode, goma
# tries to use CPU cores as many as possible. Note that, by default,
# goma runs only a few local fallback tasks in parallel at once.
# The value is the threshold of the number of local fallback failures
# to enter the mode.
# Note that 30 is just heuristically chosen by discussion with goma
# team.
#
# Specifically, this is short-term workaround of the case that all
# compile processes get local fallback. Otherwise, because goma uses
# only several processes for local fallback by default, it causes
# significant slow down of the build.
# Practically, this happens when toolchain is updated in repository,
# but prebuilt package is not yet ready. (cf. crbug.com/728971)
"GOMA_MAX_COMPILER_DISABLED_TASKS": "30",
# Disable goma soft stickiness.
# Goma was historically using `soft stickiness cookie` so that uploaded
# file cache is available as much as possible. However, such sticky
# requests are causing unbalanced server load, and the disadvantage of
# the unbalance cannot be negligible now. According to chrome's
# experiment, the disadvantage of disabling soft stickiness is
# negligible, and achieving balanced server load will have more
# advantage for entire build. (cf. crbug.com/730962)
# TODO(shinyak): This will be removed after crbug.com/730962 is
# resolved.
"GOMA_BACKEND_SOFT_STICKINESS": "false",
# Enable DepsCache. DepsCache is a cache that holds a file list that
# compiler_proxy sends to goma server for each compile. This can
# reduces a lot of I/O and calculation.
# This is the base file name under GOMA_CACHE_DIR.
"GOMA_DEPS_CACHE_FILE": "goma.deps",
# Only run one command in parallel per core.
#
# TODO(crbug.com/998076): Increase if Goma fork issue is fixed.
"NINJA_CORE_MULTIPLIER": "1",
}
def __init__(
self,
goma_dir: Union[str, os.PathLike],
goma_tmp_dir: Optional[Union[str, os.PathLike]] = None,
stage_name: Optional[str] = None,
chromeos_goma_dir: Optional[Union[str, os.PathLike]] = None,
chroot_dir: Optional[Union[str, os.PathLike]] = None,
out_dir: Optional[os.PathLike] = None,
goma_approach: Optional[GomaApproach] = None,
log_dir: Optional[Union[str, os.PathLike]] = None,
stats_filename: Optional[Union[str, os.PathLike]] = None,
counterz_filename: Optional[Union[str, os.PathLike]] = None,
) -> None:
"""Initializes Goma instance.
This ensures that |self.goma_log_dir| directory exists (if missing,
creates it).
Args:
goma_dir: Path to the Goma client used for simplechrome (outside the
chroot).
goma_tmp_dir: Path to the GOMA_TMP_DIR to be passed to goma
programs. If given, it is used. If not given, creates a
directory under /tmp in the chroot, expecting that the directory
is removed in the next run's clean up phase on bots.
stage_name: optional name of the currently running stage. E.g.
"build_packages" or "test_simple_chrome_workflow". If this is
set deps cache is enabled.
chromeos_goma_dir: Path to the Goma client used for build package.
path should be represented as outside the chroot. If None,
goma_dir will be used instead.
chroot_dir: The base chroot path to use when the chroot path is not
at the default location.
out_dir: The base out path to use when the out path is not at the
default location.
goma_approach: Indicates some extra environment variables to set
when testing alternative goma approaches.
log_dir: Allows explicitly setting the log directory. Used for the
Build API for extracting the logs afterwords. Should be the log
directory inside the chroot, based on the chroot path when
outside the chroot.
stats_filename: The name of the file to use for the
GOMA_DUMP_STATS_FILE setting. The file will be created in the
log directory.
counterz_filename: The name of the file to use for the
GOMA_DUMP_COUNTERZ_FILE setting. The file will be created in the
log directory.
Raises:
ValueError if 1) |goma_dir| does not point to a directory, or 2)
if |goma_tmp_dir| is given, but it does not point to a directory.
"""
# Sanity checks of given paths.
goma_dir = Path(goma_dir)
if goma_tmp_dir:
goma_tmp_dir = Path(goma_tmp_dir)
if chromeos_goma_dir:
chromeos_goma_dir = Path(chromeos_goma_dir)
if chroot_dir:
chroot_dir = Path(chroot_dir)
if log_dir:
log_dir = Path(log_dir)
if stats_filename:
stats_filename = Path(stats_filename)
if counterz_filename:
counterz_filename = Path(counterz_filename)
if not goma_dir.is_dir():
raise ValueError(f"goma_dir does not point a directory: {goma_dir}")
# If goma_tmp_dir is provided, it must be an existing directory.
if goma_tmp_dir and not goma_tmp_dir.is_dir():
raise ValueError(
f"GOMA_TMP_DIR does not point a directory: {goma_tmp_dir}"
)
chroot = chroot_lib.Chroot(path=chroot_dir, out_path=out_dir)
self.linux_goma_dir = goma_dir
self.chromeos_goma_dir = chromeos_goma_dir
self.goma_approach = goma_approach
# If Goma dir for ChromeOS SDK does not set, fallback to use goma_dir.
if self.chromeos_goma_dir is None:
self.chromeos_goma_dir = goma_dir
# Sanity checks of given paths.
if not self.chromeos_goma_dir.is_dir():
raise ValueError(
"chromeos_goma_dir does not point a directory: "
f"{self.chromeos_goma_dir}"
)
if stage_name:
self.goma_cache = goma_dir / "goma_cache" / stage_name
osutils.SafeMakedirs(self.goma_cache)
else:
self.goma_cache = None
if goma_tmp_dir is None:
chroot_tmp = chroot.full_path("/tmp")
# If |goma_tmp_dir| is not given, create GOMA_TMP_DIR (goma
# compiler_proxy's working directory), and its log directory.
# Create unique directory by mkdtemp under chroot's /tmp.
# Expect this directory is removed in next run's clean up phase.
goma_tmp_dir = Path(
tempfile.mkdtemp(prefix="goma_tmp_dir.", dir=chroot_tmp)
)
self.goma_tmp_dir = goma_tmp_dir
self.chroot_goma_tmp_dir = chroot.chroot_path(self.goma_tmp_dir)
self._log_dir = log_dir
# Create log directory if not exist.
if not self.goma_log_dir.is_dir():
osutils.SafeMakedirs(self.goma_log_dir)
self._stats_file = None
self._chroot_stats_file = None
self._counterz_file = None
self._chroot_counterz_file = None
if stats_filename:
self._stats_file = self.goma_log_dir / stats_filename
if counterz_filename:
self._counterz_file = self.goma_log_dir / counterz_filename
self.chroot_goma_log_dir = chroot.chroot_path(self.goma_log_dir)
if self._stats_file:
self._chroot_stats_file = chroot.chroot_path(self._stats_file)
if self._counterz_file:
self._chroot_counterz_file = chroot.chroot_path(self._counterz_file)
@property
def goma_log_dir(self):
"""Path to goma's log directory."""
return self._log_dir or self.goma_tmp_dir / "log_dir"
def GetExtraEnv(self):
"""Extra env vars set to use goma."""
result = dict(
Goma._DEFAULT_ENV_VARS,
GOMA_DIR=str(self.linux_goma_dir),
GOMA_TMP_DIR=str(self.goma_tmp_dir),
GLOG_log_dir=str(self.goma_log_dir),
)
self._AddCommonExtraEnv(result)
if hostname_util.host_is_ci_builder():
result["GOMA_GCE_SERVICE_ACCOUNT"] = "default"
result["GCE_METADATA_HOST"] = os.environ.get(
"GCE_METADATA_HOST", ""
)
if self.goma_cache:
result["GOMA_CACHE_DIR"] = str(self.goma_cache)
if self._stats_file:
result["GOMA_DUMP_STATS_FILE"] = str(self._stats_file)
if self._counterz_file:
result["GOMA_DUMP_COUNTERZ_FILE"] = str(self._counterz_file)
result["GOMA_ENABLE_COUNTERZ"] = "true"
return result
def GetChrootExtraEnv(self):
"""Extra env vars set to use goma inside chroot."""
# Note: GOMA_DIR in chroot is hardcoded.
# Please see also enter_chroot.sh.
goma_dir = Path.home() / "goma"
result = dict(
Goma._DEFAULT_ENV_VARS,
GOMA_DIR=str(goma_dir),
GOMA_TMP_DIR=str(self.chroot_goma_tmp_dir),
GLOG_log_dir=str(self.chroot_goma_log_dir),
)
self._AddCommonExtraEnv(result)
if hostname_util.host_is_ci_builder():
result["GOMA_GCE_SERVICE_ACCOUNT"] = "default"
if self.goma_cache:
result["GOMA_CACHE_DIR"] = str(
goma_dir / self.goma_cache.relative_to(self.chromeos_goma_dir)
)
if self._chroot_stats_file:
result["GOMA_DUMP_STATS_FILE"] = str(self._chroot_stats_file)
if self._chroot_counterz_file:
result["GOMA_DUMP_COUNTERZ_FILE"] = str(self._chroot_counterz_file)
result["GOMA_ENABLE_COUNTERZ"] = "true"
return result
def _AddCommonExtraEnv(self, result) -> None:
"""Sets extra env vars to use goma common to in / out chroot."""
if self.goma_approach:
result[
"GOMA_RPC_EXTRA_PARAMS"
] = self.goma_approach.rpc_extra_params
result["GOMA_SERVER_HOST"] = self.goma_approach.server_host
result["GOMA_ARBITRARY_TOOLCHAIN_SUPPORT"] = (
"true"
if self.goma_approach.arbitrary_toolchain_support
else "false"
)
def _RunGomaCtl(self, command) -> None:
goma_ctl = self.linux_goma_dir / "goma_ctl.py"
cros_build_lib.run(
["python3", goma_ctl, command], extra_env=self.GetExtraEnv()
)
def Start(self) -> None:
"""Starts goma compiler proxy."""
self._RunGomaCtl("start")
def Restart(self) -> None:
"""Restarts goma compiler proxy."""
self._RunGomaCtl("restart")
def Stop(self) -> None:
"""Stops goma compiler proxy."""
self._RunGomaCtl("stop")
def UploadLogs(self, cbb_config_name):
"""Uploads INFO files related to goma.
Args:
cbb_config_name: name of cbb_config.
Returns:
URL to the compiler_proxy log visualizer. None if unavailable.
"""
# TODO(rchandrasekar): The only client that uses UploadLogs is
# cbuildbot. Once it is removed, assess if we need to use LogsArchiver
# instead or remove this interface.
uploader = goma_util.GomaLogUploader(
self.goma_log_dir, cbb_config_name=cbb_config_name
)
return uploader.Upload()
class LogsArchiver:
"""Manages archiving goma log files.
The LogsArchiver was migrated from GomaLogUploader in
cbuildbot/goma_util.py. Unlike the GomaLogUploader, it does not interact
with GoogleStorage at all. Instead, it copies Goma files to a
client-specified archive directory.
"""
def __init__(
self, log_dir, dest_dir, stats_file=None, counterz_file=None
) -> None:
"""Initializes the archiver.
Args:
log_dir: path to the directory containing goma's INFO log files.
dest_dir: path to the target directory to which logs are written.
stats_file: name of stats file in the log_dir.
counterz_file: name of the counterz file in the log dir.
"""
self._log_dir = log_dir
self._stats_file = stats_file
self._counterz_file = counterz_file
self._dest_dir = dest_dir
# Ensure destination dir exists.
osutils.SafeMakedirs(self._dest_dir)
def Archive(self):
"""Archives goma log files, stats file, and counterz file to dest_dir.
Returns:
ArchivedFiles named tuple, which includes stats_file, counterz_file,
and list of log files. All files in the tuple were copied to
dest_dir.
"""
archived_log_files = []
archived_stats_file = None
archived_counterz_file = None
# Find log file names containing compiler_proxy-subproc.INFO.
# _ArchiveInfoFiles returns a list of tuples of (info_file_path,
# archived_file_name). We expect only 1 to be found, and add the
# filename for that tuple to archived_log_files.
compiler_proxy_subproc_paths = self._ArchiveInfoFiles(
"compiler_proxy-subproc"
)
if len(compiler_proxy_subproc_paths) != 1:
logging.warning(
"Unexpected compiler_proxy-subproc INFO files: %r",
compiler_proxy_subproc_paths,
)
else:
archived_log_files.append(compiler_proxy_subproc_paths[0][1])
# Find log file names containing compiler_proxy.INFO.
# _ArchiveInfoFiles returns a list of tuples of (info_file_path,
# archived_file_name). We expect only 1 to be found, and then need
# to use the first tuple value of the list of 1 for the full path, and
# the filename of the tuple is added to archived_log_files.
compiler_proxy_path = None
compiler_proxy_paths = self._ArchiveInfoFiles("compiler_proxy")
if len(compiler_proxy_paths) != 1:
logging.warning(
"Unexpected compiler_proxy INFO files: %r", compiler_proxy_paths
)
else:
compiler_proxy_path = compiler_proxy_paths[0][0]
archived_log_files.append(compiler_proxy_paths[0][1])
gomacc_info_file = self._ArchiveGomaccInfoFiles()
if gomacc_info_file:
archived_log_files.append(gomacc_info_file)
archived_ninja_log_filename = self._ArchiveNinjaLog(compiler_proxy_path)
if archived_ninja_log_filename:
archived_log_files.append(archived_ninja_log_filename)
# Copy stats file and counterz file if they are specified.
if self._counterz_file:
archived_counterz_file = self._CopyExpectedGomaFile(
self._counterz_file
)
if self._stats_file:
archived_stats_file = self._CopyExpectedGomaFile(self._stats_file)
return ArchivedFiles(
archived_stats_file, archived_counterz_file, archived_log_files
)
def _CopyExpectedGomaFile(self, filename: str) -> Optional[str]:
"""Copies expected goma files (stats, counterz).
Args:
filename: File to copy.
Returns:
The filename on success, None on error.
"""
file_path = os.path.join(self._log_dir, filename)
if not os.path.isfile(file_path):
logging.warning(
"Goma expected file specified, not found %s", file_path
)
return None
else:
dest_path = os.path.join(self._dest_dir, filename)
logging.info(
"Copying Goma file from %s to %s", file_path, dest_path
)
shutil.copyfile(file_path, dest_path)
return filename
def _ArchiveInfoFiles(self, pattern):
"""Archives INFO files matched with pattern, with gzip'ing.
Args:
pattern: matching path pattern.
Returns:
A list of tuples of (info_file_path, archived_file_name).
"""
# Find files matched with the pattern in |log_dir|. Sort for
# stabilization.
paths = sorted(
glob.glob(os.path.join(self._log_dir, "%s.*.INFO.*" % pattern))
)
if not paths:
logging.warning("No glog files matched with: %s", pattern)
result = []
for path in paths:
logging.info("Compressing %s", path)
archived_filename = os.path.basename(path) + ".gz"
dest_filepath = os.path.join(self._dest_dir, archived_filename)
cros_build_lib.CompressFile(path, dest_filepath)
result.append((path, archived_filename))
return result
def _ArchiveGomaccInfoFiles(self):
"""Archives gomacc INFO files, with gzip'ing.
Returns:
Archived file path. If failed, None.
"""
# Since the number of gomacc logs can be large, we'd like to compress
# them. Otherwise, archive will take long (> 10 mins). Each gomacc logs
# file size must be small (around 4KB).
# Find files matched with the pattern in |log_dir|.
# The paths were themselves used as the inputs for the create
# tarball, but there can be too many of them. As long as we have
# files we'll just tar up the entire directory.
gomacc_paths = glob.glob(os.path.join(self._log_dir, "gomacc.*.INFO.*"))
if not gomacc_paths:
# gomacc logs won't be made every time.
# Only when goma compiler_proxy has
# crashed. So it's usual gomacc logs are not found.
logging.info("No gomacc logs found")
return None
tarball_name = os.path.basename(min(gomacc_paths)) + ".tar.gz"
tarball_path = os.path.join(self._dest_dir, tarball_name)
cros_build_lib.CreateTarball(
tarball_path,
cwd=self._log_dir,
compression=cros_build_lib.CompressionType.GZIP,
)
return tarball_name
def _ArchiveNinjaLog(self, compiler_proxy_path):
"""Archives .ninja_log file and its related metadata.
This archives the .ninja_log file generated by ninja to build Chrome.
Also, it appends some related metadata at the end of the file following
'# end of ninja log' marker.
Args:
compiler_proxy_path: Path to the compiler proxy, which will be
contained in the metadata.
Returns:
The name of the archived file.
"""
ninja_log_path = os.path.join(self._log_dir, "ninja_log")
if not os.path.exists(ninja_log_path):
logging.warning("ninja_log is not found: %s", ninja_log_path)
return None
ninja_log_content = osutils.ReadFile(ninja_log_path)
try:
st = os.stat(ninja_log_path)
ninja_log_mtime = datetime.datetime.fromtimestamp(st.st_mtime)
except OSError:
logging.exception("Failed to get timestamp: %s", ninja_log_path)
return None
ninja_log_info = self._BuildNinjaInfo(compiler_proxy_path)
# Append metadata at the end of the log content.
ninja_log_content += "# end of ninja log\n" + json.dumps(ninja_log_info)
# Aligned with goma_utils in chromium bot.
pid = os.getpid()
archive_ninja_log_path = os.path.join(
self._log_dir,
"ninja_log.%s.%s.%s.%d"
% (
getpass.getuser(),
hostname_util.get_host_name(),
ninja_log_mtime.strftime("%Y%m%d-%H%M%S"),
pid,
),
)
osutils.WriteFile(archive_ninja_log_path, ninja_log_content)
archived_filename = os.path.basename(archive_ninja_log_path) + ".gz"
archived_path = os.path.join(self._dest_dir, archived_filename)
cros_build_lib.CompressFile(archive_ninja_log_path, archived_path)
return archived_filename
def _BuildNinjaInfo(self, compiler_proxy_path):
"""Reads metadata for the ninja run.
Each metadata should be written into a dedicated file in the log
directory. Read the info, and build the dict containing metadata.
Args:
compiler_proxy_path: Path to the compiler_proxy log file.
Returns:
A dict of the metadata.
"""
info = {"platform": "chromeos"}
command_path = os.path.join(self._log_dir, "ninja_command")
if os.path.exists(command_path):
info["cmdline"] = shlex.split(
osutils.ReadFile(command_path).strip()
)
cwd_path = os.path.join(self._log_dir, "ninja_cwd")
if os.path.exists(cwd_path):
info["cwd"] = osutils.ReadFile(cwd_path).strip()
exit_path = os.path.join(self._log_dir, "ninja_exit")
if os.path.exists(exit_path):
info["exit"] = int(osutils.ReadFile(exit_path).strip())
env_path = os.path.join(self._log_dir, "ninja_env")
if os.path.exists(env_path):
# env is null-byte separated, and has a trailing null byte.
content = osutils.ReadFile(env_path).rstrip("\0")
info["env"] = dict(
line.split("=", 1) for line in content.split("\0")
)
if compiler_proxy_path:
info["compiler_proxy_info"] = os.path.basename(compiler_proxy_path)
return info