blob: 7880cffd7d0bdbb621454baa28bc2e9669323659 [file] [log] [blame]
#!/usr/bin/env vpython
# Copyright 2015 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.
"""This script rebuilds Python & Go universes of infra.git multiverse and
invokes CIPD client to package and upload chunks of it to the CIPD repository as
individual packages.
See build/packages/*.yaml for definition of packages and README.md for more
details.
"""
import argparse
import collections
import contextlib
import copy
import glob
import hashlib
import json
import os
import platform
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import yaml
# Root of infra.git repository.
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Root of infra gclient solution.
GCLIENT_ROOT = os.path.dirname(ROOT)
# Where to upload packages to by default.
PACKAGE_REPO_SERVICE = 'https://chrome-infra-packages.appspot.com'
# Hash algorithm to use for calculating instance IDs.
HASH_ALGO = 'sha256'
# True if running on Windows.
IS_WINDOWS = sys.platform == 'win32'
# .exe on Windows.
EXE_SUFFIX = '.exe' if IS_WINDOWS else ''
# All GOARCHs we are willing to cross-compile for.
KNOWN_GOARCHS = frozenset([
'386',
'amd64',
'arm',
'arm64',
'mips',
'mipsle',
'mips64',
'mips64le',
'ppc64',
'ppc64le',
's390x',
])
class PackageDefException(Exception):
"""Raised if a package definition is invalid."""
def __init__(self, path, msg):
super(PackageDefException, self).__init__('%s: %s' % (path, msg))
class BuildException(Exception):
"""Raised on errors during package build step."""
class UploadException(Exception):
"""Raised on errors during package upload step."""
class PackageDef(collections.namedtuple(
'_PackageDef', ('path', 'pkg_def'))):
"""Represents parsed package *.yaml file."""
@property
def name(self):
"""Returns name of YAML file (without the directory path and extension)."""
return os.path.splitext(os.path.basename(self.path))[0]
@property
def disabled(self):
"""Returns True if the package should be excluded from the build."""
return self.pkg_def.get('disabled', False)
@property
def uses_python_env(self):
"""Returns True if 'uses_python_env' in the YAML file is set."""
return bool(self.pkg_def.get('uses_python_env'))
@property
def update_latest_ref(self):
"""Returns True if 'update_latest_ref' in the YAML file is set.
Defaults to True."""
return bool(self.pkg_def.get('update_latest_ref', True))
@property
def go_packages(self):
"""Returns a list of Go packages that must be installed for this package."""
return self.pkg_def.get('go_packages') or []
def cgo_enabled(self, target_goos):
"""Either True, False or None (meaning "let go decide itself").
Ignored when cross-compiling.
"""
val = self.pkg_def.get('go_build_environ', {}).get('CGO_ENABLED')
if isinstance(val, dict):
val = val.get(target_goos)
return None if val is None else bool(val)
@property
def pkg_root(self):
"""Absolute path to a package root directory."""
root = self.pkg_def['root'].replace('/', os.sep)
if os.path.isabs(root):
return root
return os.path.abspath(os.path.join(os.path.dirname(self.path), root))
def validate(self):
"""Raises PackageDefException if the package definition looks invalid."""
for var_name in self.pkg_def.get('go_build_environ', {}):
if var_name != 'CGO_ENABLED':
raise PackageDefException(
self.path,
'Only "CGO_ENABLED" is supported in "go_build_environ" currently')
def should_visit(self):
"""Returns True if package targets the current platform."""
# If the package doesn't have 'platforms' set, assume it doesn't want to be
# cross-compiled, and supports only native host platform or it's platform
# independent. Otherwise build it only if the target of the compilation is
# declared as supported. Note that these are CIPD-flavored platform strings
# (e.g. "mac-amd64"), exactly like they appear in CIPD package names.
platforms = self.pkg_def.get('platforms')
if not platforms:
return not is_cross_compiling()
return get_package_vars()['platform'] in platforms
def preprocess(self, pkg_vars):
"""Parses the definition and filters/extends it before passing to CIPD.
This process may generate additional files that are put into the package.
Args:
pkg_vars: dict with variables passed to cipd as -pkg-var.
Returns:
(Path to filtered package definition YAML, list of generated files).
"""
pkg_def = copy.deepcopy(self.pkg_def)
gen_files = []
bat_files = [
d['file'] for d in pkg_def['data'] if d.get('generate_bat_shim')
]
for cp in pkg_def.get('copies', ()):
plat = cp.get('platforms')
if plat and pkg_vars['platform'] not in plat:
continue
dst = os.path.join(self.pkg_root, render_path(cp['dst'], pkg_vars))
shutil.copy(os.path.join(self.pkg_root, render_path(cp['src'], pkg_vars)),
dst)
pkg_def['data'].append({
'file': os.path.relpath(dst, self.pkg_root).replace(os.sep, '/')
})
if cp.get('generate_bat_shim'):
bat_files.append(cp['dst'])
gen_files.append(dst)
if not is_targeting_windows(pkg_vars):
for sym in pkg_def.get('posix_symlinks', ()):
dst = os.path.join(self.pkg_root, render_path(sym['dst'], pkg_vars))
try:
os.remove(dst)
except OSError:
pass
os.symlink(
os.path.join(self.pkg_root, render_path(sym['src'], pkg_vars)),
dst)
pkg_def['data'].append({
'file': os.path.relpath(dst, self.pkg_root).replace(os.sep, '/')
})
gen_files.append(dst)
# Generate *.bat shims when targeting Windows.
if is_targeting_windows(pkg_vars):
for f in bat_files:
# Generate actual *.bat.
bat_abs = generate_bat_shim(self.pkg_root, render_path(f, pkg_vars))
# Make it part of the package definition (use slash paths there).
pkg_def['data'].append({
'file': os.path.relpath(bat_abs, self.pkg_root).replace(os.sep, '/')
})
# Stage it for cleanup.
gen_files.append(bat_abs)
# Keep generated yaml in the same directory to avoid rewriting paths.
out_path = os.path.join(
os.path.dirname(self.path), self.name + '.processed_yaml')
with open(out_path, 'w') as f:
json.dump(pkg_def, f)
return out_path, gen_files
# Carries modifications for go-related env vars.
#
# If a field has value None, it will be popped from the environment in
# 'apply_to_environ'.
class GoEnviron(collections.namedtuple(
'GoEnviron', ['GOOS', 'GOARCH', 'CGO_ENABLED'])):
@staticmethod
def host_native():
"""Returns GoEnviron that instructs Go to not cross-compile."""
return GoEnviron(GOOS=None, GOARCH=None, CGO_ENABLED=None)
@staticmethod
def from_environ():
"""Reads GoEnviron from the current os.environ.
If CGO_ENABLED is not given, picks the default based on whether we are
cross-compiling or not. cgo is disabled by default when cross-compiling.
"""
cgo = os.environ.get('CGO_ENABLED')
if cgo is None:
cgo = not os.environ.get('GOOS')
else:
cgo = cgo == '1'
return GoEnviron(
GOOS=os.environ.get('GOOS'),
GOARCH=os.environ.get('GOARCH'),
CGO_ENABLED=cgo)
def apply_to_environ(self):
"""Applies GoEnviron to the current os.environ."""
if self.GOOS is not None:
os.environ['GOOS'] = self.GOOS
else:
os.environ.pop('GOOS', None)
if self.GOARCH is not None:
os.environ['GOARCH'] = self.GOARCH
else:
os.environ.pop('GOARCH', None)
if self.CGO_ENABLED is not None:
os.environ['CGO_ENABLED'] = '1' if self.CGO_ENABLED else '0'
else:
os.environ.pop('CGO_ENABLED', None)
def render_path(p, pkg_vars):
"""Renders ${...} substitutions in paths, converts them to native slash."""
for k, v in pkg_vars.iteritems():
assert '${' not in v # just in case, to avoid recursive expansion
p = p.replace('${%s}' % k, v)
return p.replace('/', os.sep)
def generate_bat_shim(pkg_root, target_rel):
"""Writes a shim file side-by-side with target and returns abs path to it."""
target_name = os.path.basename(target_rel)
bat_name = os.path.splitext(target_name)[0] + '.bat'
base_dir = os.path.dirname(os.path.join(pkg_root, target_rel))
bat_path = os.path.join(base_dir, bat_name)
with open(bat_path, 'w') as fd:
fd.write('\n'.join([ # python turns \n into CRLF
'@set CIPD_EXE_SHIM="%%~dp0%s"' % (target_name,),
'@shift',
'@%CIPD_EXE_SHIM% %*',
''
]))
return bat_path
def is_cross_compiling():
"""Returns True if using GOOS or GOARCH env vars.
We also check at the start of the script that if one of them is used, then
the other is specified as well.
"""
return bool(os.environ.get('GOOS')) or bool(os.environ.get('GOARCH'))
def get_env_dot_py():
if os.environ.get('GOOS') == 'android':
return 'mobile_env.py'
else:
return 'env.py'
def run_python(script, args):
"""Invokes a python script via the root python interpreter.
Escapes virtualenv if finds itself running with VIRTUAL_ENV env var set.
Raises:
BuildException if couldn't find a proper python binary.
subprocess.CalledProcessError on non zero exit code.
"""
environ = os.environ.copy()
python_exe = sys.executable
venv = environ.pop('VIRTUAL_ENV')
if venv:
path = environ['PATH'].split(os.pathsep)
path = [p for p in path if not p.startswith(venv+os.sep)]
environ['PATH'] = os.pathsep.join(path)
# Popen doesn't use new env['PATH'] to search for binaries. Do it ourselves.
for p in path:
candidate = os.path.join(p, 'python'+EXE_SUFFIX)
if os.path.exists(candidate):
python_exe = candidate
break
else:
raise BuildException(
'Could\'n find python%s in %s' % (EXE_SUFFIX, environ['PATH']))
print 'Running %s %s' % (script, ' '.join(args))
print ' via %s' % python_exe
print ' in %s' % os.getcwd()
subprocess.check_call(
args=['python', '-u', script] + list(args),
executable=python_exe,
env=environ)
def find_cipd():
"""Finds a CIPD client in PATH."""
exts = ('.exe', '.bat') if sys.platform == 'win32' else ('',)
for p in os.environ.get('PATH', '').split(os.pathsep):
base = os.path.join(p, 'cipd')
for ext in exts:
candidate = base + ext
if os.path.isfile(candidate):
return candidate
return 'cipd' + EXE_SUFFIX
def run_cipd(cipd_exe, cmd, args):
"""Invokes CIPD, parsing -json-output result.
Args:
cipd_exe: path to cipd client binary to run.
cmd: cipd subcommand to run.
args: list of command line arguments to pass to the subcommand.
Returns:
(Process exit code, parsed JSON output or None).
"""
temp_file = None
try:
fd, temp_file = tempfile.mkstemp(suffix='.json', prefix='cipd_%s' % cmd)
os.close(fd)
cmd_line = [cipd_exe, cmd, '-json-output', temp_file] + list(args)
print 'Running %s' % ' '.join(cmd_line)
exit_code = subprocess.call(args=cmd_line, executable=cmd_line[0])
try:
with open(temp_file, 'r') as f:
json_output = json.load(f)
except (IOError, ValueError):
json_output = None
return exit_code, json_output
finally:
try:
if temp_file:
os.remove(temp_file)
except OSError:
pass
def print_title(title):
"""Pretty prints a banner to stdout."""
sys.stdout.flush()
sys.stderr.flush()
print
print '-' * 80
print title
print '-' * 80
def print_go_step_title(title):
"""Same as 'print_title', but also appends values of GOOS, GOARCH, etc."""
go_vars = [
(k, os.environ[k])
for k in ('GOOS', 'GOARCH', 'GOARM', 'CGO_ENABLED')
if k in os.environ
]
if go_vars:
title += '\n' + '-' * 80
for k, v in go_vars:
title += '\n %s=%s' % (k, v)
print_title(title)
@contextlib.contextmanager
def hacked_workspace(go_workspace, go_environ):
"""Symlinks Go workspace into new root, modifies os.environ.
Go toolset embeds absolute paths to *.go files into the executable. Use
symlink with stable path to make executables independent of checkout path.
Args:
go_workspace: path to 'infra/go' or 'infra_internal/go'.
go_environ: instance of GoEnviron object with go related env vars.
Yields:
Path where go_workspace is symlinked to.
"""
new_root = None
new_workspace = go_workspace
if not IS_WINDOWS:
new_root = '/tmp/_chrome_infra_build'
if os.path.exists(new_root):
assert os.path.islink(new_root)
os.remove(new_root)
os.symlink(GCLIENT_ROOT, new_root)
rel = os.path.relpath(go_workspace, GCLIENT_ROOT)
assert not rel.startswith('..'), rel
new_workspace = os.path.join(new_root, rel)
orig_environ = os.environ.copy()
go_environ.apply_to_environ()
# Make sure we build ARMv6 code even if the host is ARMv7. See the comment in
# get_host_package_vars for reasons why. Also explicitly set GOARM to 6 when
# cross-compiling (it should be '6' in this case by default anyway).
plat = platform.machine().lower()
if plat.startswith('arm') or os.environ.get('GOARCH') == 'arm':
os.environ['GOARM'] = '6'
else:
os.environ.pop('GOARM', None)
# Debug info (DW_AT_comp_dir attribute in particular) contains current
# working directory, which by default depends on the build directory, making
# the build non-deterministic. 'ld' uses os.Getcwd(), and os.Getcwd()'s doc
# says: "If the current directory can be reached via multiple paths (due to
# symbolic links), Getwd may return any one of them." It happens indeed. So we
# can't switch to 'new_workspace'. Switch to '/' instead, cwd is actually not
# important when building Go code.
#
# Protip: To view debug info in an obj file:
# gobjdump -g <binary>
# gobjdump -g --target=elf32-littlearm <binary>
# (gobjdump is part of binutils package in Homebrew).
prev_cwd = os.getcwd()
if not IS_WINDOWS:
os.chdir('/')
try:
yield new_workspace
finally:
os.chdir(prev_cwd)
# Apparently 'os.environ = orig_environ' doesn't actually modify process
# environment, only modifications of os.environ object itself do.
for k, v in orig_environ.iteritems():
os.environ[k] = v
for k in os.environ.keys():
if k not in orig_environ:
os.environ.pop(k)
if new_root:
os.remove(new_root)
def bootstrap_go_toolset(go_workspace):
"""Makes sure go is installed and returns its 'go env' and version.
Used to verify that our platform detection in get_host_package_vars() matches
the Go toolset being used.
"""
with hacked_workspace(go_workspace, GoEnviron.host_native()) as new_workspace:
print_go_step_title('Making sure Go toolset is installed')
# env.py does the actual job of bootstrapping if the toolset is missing.
output = subprocess.check_output(
args=[
'python', '-u', os.path.join(new_workspace, get_env_dot_py()),
'go', 'env',
],
executable=sys.executable)
# See https://github.com/golang/go/blob/master/src/cmd/go/env.go for format
# of the output.
print 'Go environ:'
print output.strip()
env = {}
for line in output.splitlines():
k, _, v = line.lstrip('set ').partition('=')
if v.startswith('"') and v.endswith('"'):
v = v.strip('"')
env[k] = v
# This would be something like "go version go1.15.8 darwin/amd64".
print_go_step_title('Go version')
output = subprocess.check_output(
args=[
'python', '-u', os.path.join(new_workspace, get_env_dot_py()),
'go', 'version',
],
executable=sys.executable)
print output.strip()
# We want only "go1.15.8" part.
version = re.match(r'go version (go[\d\.]+)', output).group(1)
return env, version
def run_go_clean(go_workspace, go_environ, packages):
"""Removes object files and executables left from building given packages.
Transitively cleans all dependencies (including stdlib!) and removes
executables from GOBIN.
Args:
go_workspace: path to 'infra/go' or 'infra_internal/go'.
go_environ: instance of GoEnviron object with go related env vars.
packages: list of go packages to clean (can include '...' patterns).
"""
with hacked_workspace(go_workspace, go_environ) as new_workspace:
print_go_step_title('Cleaning:\n %s' % '\n '.join(packages))
subprocess.check_call(
args=[
'python', '-u', os.path.join(new_workspace, get_env_dot_py()),
'go', 'clean', '-i', '-r',
] + list(packages),
executable=sys.executable,
stderr=subprocess.STDOUT)
# Above command is either silent (without '-x') or too verbose (with '-x').
# Prefer silent version, but add a note that it's alright.
print 'Done.'
def run_go_install(go_workspace, go_environ, packages, rebuild=False):
"""Builds (and installs) Go packages into GOBIN via 'go install ...'.
Compiles and installs packages into default GOBIN, which is <go_workspace>/bin
(it is setup by go/env.py).
Args:
go_workspace: path to 'infra/go' or 'infra_internal/go'.
go_environ: instance of GoEnviron object with go related env vars.
packages: list of go packages to build (can include '...' patterns).
rebuild: if True, will forcefully rebuild all dependences.
"""
rebuild_opt = ['-a'] if rebuild else []
title = 'Rebuilding' if rebuild else 'Building'
with hacked_workspace(go_workspace, go_environ) as new_workspace:
print_go_step_title('%s:\n %s' % (title, '\n '.join(packages)))
subprocess.check_call(
args=[
'python', '-u', os.path.join(new_workspace, get_env_dot_py()),
'go', 'install', '-v',
] + rebuild_opt + list(packages),
executable=sys.executable,
stderr=subprocess.STDOUT)
def run_go_build(go_workspace, go_environ, package, output, rebuild=False):
"""Builds single Go package.
Args:
go_workspace: path to 'infra/go' or 'infra_internal/go'.
go_environ: instance of GoEnviron object with go related env vars.
package: go package to build.
output: where to put the resulting binary.
rebuild: if True, will forcefully rebuild all dependences.
"""
rebuild_opt = ['-a'] if rebuild else []
title = 'Rebuilding' if rebuild else 'Building'
with hacked_workspace(go_workspace, go_environ) as new_workspace:
print_go_step_title('%s %s' % (title, package))
subprocess.check_call(
args=[
'python', '-u', os.path.join(new_workspace, get_env_dot_py()),
'go', 'build',
] + rebuild_opt + ['-v', '-o', output, package],
executable=sys.executable,
stderr=subprocess.STDOUT)
def build_go_code(go_workspace, pkg_defs):
"""Builds and installs all Go packages used by the given PackageDefs.
Understands GOOS and GOARCH and uses slightly different build strategy when
cross-compiling. In the end <go_workspace>/bin will have all built binaries,
and only them (regardless of whether we are cross-compiling or not).
Args:
go_workspace: path to 'infra/go' or 'infra_internal/go'.
pkg_defs: list of PackageDef objects that define what to build.
"""
# TODO(vadimsh): Revisit this once Go 1.10 (with its content-addressed build
# cache) is released. In theory, Go 1.10 will be smart enough to efficiently
# build packages in arbitrary order, regardless of their intended build
# environment. Until then, group 'go build' calls by the environment, to
# avoid rebuilding common packages all the time.
# Exclude all disabled packages.
pkg_defs = [p for p in pkg_defs if not p.disabled]
# Whatever GOOS, GOARCH, etc were passed from outside. They are set when
# cross-compiling.
default_environ = GoEnviron.from_environ()
# The OS we compiling for (defaulting to the host OS).
target_goos = default_environ.GOOS or get_host_goos()
# Grab a set of all go packages we need to build and install into GOBIN,
# figuring out a go environment they want.
go_packages = {} # go package name => GoEnviron
for pkg_def in pkg_defs:
pkg_env = default_environ
if not is_cross_compiling():
cgo_enabled = pkg_def.cgo_enabled(target_goos)
if cgo_enabled is not None:
pkg_env = default_environ._replace(CGO_ENABLED=cgo_enabled)
for name in pkg_def.go_packages:
if name in go_packages and go_packages[name] != pkg_env:
raise BuildException(
'Go package %s is being built in two different go environments '
'(%s and %s), this is not supported' %
(name, pkg_env, go_packages[name]))
go_packages[name] = pkg_env
# Group packages by the environment they want.
packages_per_env = {} # GoEnviron => [str]
for name, pkg_env in go_packages.iteritems():
packages_per_env.setdefault(pkg_env, []).append(name)
# Execute build command for each individual environment.
for pkg_env, to_install in sorted(packages_per_env.iteritems()):
to_install = sorted(to_install)
if not to_install:
continue
# Make sure there are no stale files in the workspace.
run_go_clean(go_workspace, pkg_env, to_install)
if not is_cross_compiling():
# If not cross-compiling, build all Go code in a single "go install" step,
# it's faster that way. We can't do that when cross-compiling, since
# 'go install' isn't supposed to be used for cross-compilation and the
# toolset actively complains with "go install: cannot install
# cross-compiled binaries when GOBIN is set".
run_go_install(go_workspace, pkg_env, to_install)
else:
# Prebuild stdlib once. 'go build' calls below are discarding build
# results, so it's better to install as much shared stuff as possible
# beforehand.
run_go_install(go_workspace, pkg_env, ['std'])
# Build packages one by one and put the resulting binaries into GOBIN, as
# if they were installed there. It's where the rest of the build.py code
# expects them to be (see also 'root' property in package definition
# YAMLs).
go_bin = os.path.join(go_workspace, 'bin')
exe_suffix = get_package_vars()['exe_suffix']
for pkg in to_install:
bin_name = pkg[pkg.rfind('/')+1:] + exe_suffix
run_go_build(go_workspace, pkg_env, pkg, os.path.join(go_bin, bin_name))
def enumerate_packages(package_def_dir, package_def_files):
"""Returns a list PackageDef instances for files in build/packages/*.yaml.
Args:
package_def_dir: path to build/packages dir to search for *.yaml.
package_def_files: optional list of filenames to limit results to.
Returns:
List of PackageDef instances parsed from *.yaml files under packages_dir.
"""
paths = []
if not package_def_files:
# All existing packages by default.
paths = glob.glob(os.path.join(package_def_dir, '*.yaml'))
else:
# Otherwise pick only the ones in 'package_def_files' list.
for name in package_def_files:
abs_path = os.path.abspath(os.path.join(package_def_dir, name))
if not os.path.isfile(abs_path):
raise PackageDefException(name, 'No such package definition file')
paths.append(abs_path)
# Load and validate YAMLs.
pkgs = []
for p in sorted(paths):
pkg = PackageDef(p, read_yaml(p))
pkg.validate()
pkgs.append(pkg)
return pkgs
def read_yaml(path):
"""Returns content of YAML file as python dict."""
with open(path, 'rb') as f:
return yaml.safe_load(f)
def get_package_vars():
"""Returns a dict with variables that describe the package target environment.
Variables can be referenced in the package definition YAML as
${variable_name}. It allows to reuse exact same definition file for similar
packages (e.g. packages with same cross platform binary, but for different
platforms).
If running in cross-compilation mode, uses GOOS and GOARCH to figure out the
target platform instead of examining the host environment.
"""
if is_cross_compiling():
return get_target_package_vars()
return get_host_package_vars()
def get_target_package_vars():
"""Returns a dict with variables that describe cross-compilation target env.
Examines os.environ for GOOS, GOARCH and GOARM.
The returned dict contains only 'platform' and 'exe_suffix' entries.
"""
assert is_cross_compiling()
goos = os.environ['GOOS']
goarch = os.environ['GOARCH']
if goarch not in KNOWN_GOARCHS:
raise BuildException('Unsupported GOARCH %s' % goarch)
# There are many ARMs, pick the concrete instruction set. 'v6' is the default,
# don't try to support other variants for now. Note that 'GOARM' doesn't apply
# to 'arm64' arch.
#
# See:
# https://golang.org/doc/install/source#environment
# https://github.com/golang/go/wiki/GoArm
if goarch == 'arm':
goarm = os.environ.get('GOARM', '6')
if goarm != '6':
raise BuildException('Unsupported GOARM value %s' % goarm)
arch = 'armv6l'
else:
arch = goarch
# We use 'mac' instead of 'darwin'.
if goos == 'darwin':
goos = 'mac'
return {
'exe_suffix': '.exe' if goos == 'windows' else '',
'platform': '%s-%s' % (goos, arch),
}
def get_linux_host_arch():
"""Returns: The Linux host architecture, or None if it could not be resolved.
"""
try:
# Query "dpkg" to identify the userspace architecture.
return subprocess.check_output(['dpkg', '--print-architecture']).strip()
except OSError:
# This Linux distribution doesn't use "dpkg".
return None
def get_host_package_vars():
"""Returns a dict with variables that describe the current host environment.
The returned platform may not match the machine environment exactly, but it is
compatible with it.
For example, on ARMv7 machines we claim that we are in fact running ARMv6
(which is subset of ARMv7), since we don't really care about v7 over v6
difference and want to reduce the variability in supported architectures
instead.
Similarly, if running on 64-bit Linux with 32-bit user space (based on python
interpreter bitness), we claim that machine is 32-bit, since most 32-bit Linux
Chrome Infra bots are in fact running 64-bit kernels with 32-bit userlands.
"""
# linux, mac or windows.
platform_variant = {
'darwin': 'mac',
'linux2': 'linux',
'win32': 'windows',
}.get(sys.platform)
if not platform_variant:
raise ValueError('Unknown OS: %s' % sys.platform)
sys_arch = None
if sys.platform == 'linux2':
sys_arch = get_linux_host_arch()
# If we didn't override our system architecture, identify it using "platform".
sys_arch = sys_arch or platform.machine()
# amd64, 386, etc.
platform_arch = {
'amd64': 'amd64',
'i386': '386',
'i686': '386',
'x86': '386',
'x86_64': 'amd64',
'armv6l': 'armv6l',
'armv7l': 'armv6l', # we prefer to use older instruction set for builds
}.get(sys_arch.lower())
if not platform_arch:
raise ValueError('Unknown machine arch: %s' % sys_arch)
# Most 32-bit Linux Chrome Infra bots are in fact running 64-bit kernel with
# 32-bit userland. Detect this case (based on bitness of the python
# interpreter) and report the bot as '386'.
if (platform_variant == 'linux' and
platform_arch == 'amd64' and
sys.maxsize == (2 ** 31) - 1):
platform_arch = '386'
return {
# e.g. '.exe' or ''.
'exe_suffix': EXE_SUFFIX,
# e.g. 'linux-amd64'
'platform': '%s-%s' % (platform_variant, platform_arch),
}
def get_host_goos():
"""Returns GOOS value matching the host that builds the package."""
goos = {
'darwin': 'darwin',
'linux2': 'linux',
'win32': 'windows',
}.get(sys.platform)
if not goos:
raise ValueError('Unknown OS: %s' % sys.platform)
return goos
def is_targeting_windows(pkg_vars):
"""Returns true if 'platform' in pkg_vars indicates Windows."""
return pkg_vars['platform'].startswith('windows-')
def build_pkg(cipd_exe, pkg_def, out_file, package_vars):
"""Invokes CIPD client to build a package.
Args:
cipd_exe: path to cipd client binary to use.
pkg_def: instance of PackageDef representing this package.
out_file: where to store the built package.
package_vars: dict with variables to pass as -pkg-var to cipd.
Returns:
{'package': <name>, 'instance_id': <sha1>}
Raises:
BuildException on error.
"""
print_title('Building: %s' % os.path.basename(out_file))
# Make sure not stale output remains.
if os.path.isfile(out_file):
os.remove(out_file)
# Parse the definition and filter/extend it before passing to CIPD. This
# process may generate additional files that are put into the package. We
# delete them afterwards to avoid polluting GOBIN.
processed_yaml, tmp_files = pkg_def.preprocess(package_vars)
try:
# Build the package.
args = ['-pkg-def', processed_yaml]
for k, v in sorted(package_vars.items()):
args.extend(['-pkg-var', '%s:%s' % (k, v)])
args.extend(['-out', out_file])
args.extend(['-hash-algo', HASH_ALGO])
exit_code, json_output = run_cipd(cipd_exe, 'pkg-build', args)
if exit_code:
print
print >> sys.stderr, 'FAILED! ' * 10
raise BuildException('Failed to build the CIPD package, see logs')
# Expected result is {'package': 'name', 'instance_id': 'sha1'}
info = json_output['result']
print '%s %s' % (info['package'], info['instance_id'])
return info
finally:
for f in [processed_yaml] + tmp_files:
os.remove(f)
def upload_pkg(cipd_exe, pkg_file, service_url, tags, update_latest_ref,
service_account):
"""Uploads existing *.cipd file to the storage and tags it.
Args:
cipd_exe: path to cipd client binary to use.
pkg_file: path to *.cipd file to upload.
service_url: URL of a package repository service.
tags: a list of tags to attach to uploaded package instance.
update_latest_ref: a bool of whether or not to update the 'latest' CIPD ref
service_account: path to *.json file with service account to use.
Returns:
{'package': <name>, 'instance_id': <sha1>}
Raises:
UploadException on error.
"""
print_title('Uploading: %s' % os.path.basename(pkg_file))
args = ['-service-url', service_url]
for tag in sorted(tags):
args.extend(['-tag', tag])
if update_latest_ref:
args.extend(['-ref', 'latest'])
if service_account:
args.extend(['-service-account-json', service_account])
args.extend(['-hash-algo', HASH_ALGO])
args.append(pkg_file)
exit_code, json_output = run_cipd(cipd_exe, 'pkg-register', args)
if exit_code:
print
print >> sys.stderr, 'FAILED! ' * 10
raise UploadException('Failed to upload the CIPD package, see logs')
info = json_output['result']
info['url'] = '%s/p/%s/+/%s' % (
service_url, info['package'], info['instance_id'])
print '%s %s' % (info['package'], info['instance_id'])
return info
def get_build_out_file(package_out_dir, pkg_def):
"""Returns a path where to put built *.cipd package file.
Args:
package_out_dir: root directory where to put *.cipd files.
pkg_def: instance of PackageDef being built.
"""
# When cross-compiling, append a suffix to package file name to indicate that
# it's for foreign platform.
sfx = ''
if is_cross_compiling():
sfx = '+' + get_target_package_vars()['platform']
return os.path.join(package_out_dir, pkg_def.name + sfx + '.cipd')
def run(
go_workspace,
build_callback,
builder,
package_def_dir,
package_out_dir,
package_def_files,
build,
upload,
service_url,
tags,
service_account_json,
json_output,
want_refresh_python):
"""Rebuilds python and Go universes and CIPD packages.
Args:
go_workspace: path to 'infra/go' or 'infra_internal/go'.
build_callback: called to build binaries, virtual environment, etc.
builder: name of CI buildbot builder that invoked the script.
package_def_dir: path to build/packages dir to search for *.yaml.
package_out_dir: where to put built packages.
package_def_files: names of *.yaml files in package_def_dir or [] for all.
build: False to skip building packages (valid only when upload==True).
upload: True to also upload built packages, False just to build them.
service_url: URL of a package repository service.
tags: a list of tags to attach to uploaded package instances.
service_account_json: path to *.json service account credential.
json_output: path to *.json file to write info about built packages to.
want_refresh_python: want a refreshed python ENV.
Returns:
0 on success, 1 or error.
"""
assert build or upload, 'Both build and upload are False, nothing to do'
# We need both GOOS and GOARCH or none.
if is_cross_compiling():
if not os.environ.get('GOOS') or not os.environ.get('GOARCH'):
print >> sys.stderr, (
'When cross-compiling both GOOS and GOARCH environment variables '
'must be set.')
return 1
if os.environ.get('GOARM', '6') != '6':
print >> sys.stderr, 'Only GOARM=6 is supported for now.'
return 1
# Load all package definitions and pick ones we want to build (based on
# whether we are cross-compiling or not).
try:
defs = enumerate_packages(package_def_dir, package_def_files)
except PackageDefException as exc:
print >> sys.stderr, exc
return 1
packages_to_visit = [p for p in defs if p.should_visit()]
# Make sure we have a Go toolset and it matches the host platform we detected
# in get_host_package_vars(). Otherwise we may end up uploading wrong binaries
# under host platform CIPD package suffix. It's important on Linux with 64-bit
# kernel and 32-bit userland (we must use 32-bit Go in that case, even if
# 64-bit Go works too).
go_env, go_ver = bootstrap_go_toolset(go_workspace)
host_vars = get_host_package_vars()
expected_arch = host_vars['platform'].split('-')[1]
if go_env['GOHOSTARCH'] != expected_arch:
print >> sys.stderr, (
'Go toolset GOHOSTARCH (%s) doesn\'t match expected architecture (%s)' %
(go_env['GOHOSTARCH'], expected_arch))
return 1
# Append tags related to the build host. They are especially important when
# cross-compiling: cross-compiled packages can be identified by comparing the
# platform in the package name with value of 'build_host_platform' tag.
tags = list(tags)
tags.append('build_host_hostname:' + socket.gethostname().split('.')[0])
tags.append('build_host_platform:' + host_vars['platform'])
tags.append('go_version:' + go_ver)
print_title('Overview')
if upload:
print 'Service URL: %s' % service_url
print
if builder:
print 'Package definition files to process on %s:' % builder
else:
print 'Package definition files to process:'
for pkg_def in packages_to_visit:
print ' %s' % pkg_def.name
if not packages_to_visit:
print ' <none>'
print
print 'Variables to pass to CIPD:'
package_vars = get_package_vars()
for k, v in sorted(package_vars.items()):
print ' %s = %s' % (k, v)
if upload and tags:
print
print 'Tags to attach to uploaded packages:'
for tag in sorted(tags):
print ' %s' % tag
if not packages_to_visit:
print
print 'Nothing to do.'
return 0
# Find a CIPD client in PATH to use for building and uploading packages.
print_title('CIPD client')
cipd_exe = find_cipd()
print 'Binary: %s' % cipd_exe
subprocess.check_call(['cipd', 'version'], executable=cipd_exe)
# Remove old build artifacts to avoid stale files in case the script crashes
# for some reason.
if build:
print_title('Cleaning %s' % package_out_dir)
if not os.path.exists(package_out_dir):
os.makedirs(package_out_dir)
cleaned = False
for pkg_def in packages_to_visit:
out_file = get_build_out_file(package_out_dir, pkg_def)
if os.path.exists(out_file):
print 'Removing stale %s' % os.path.basename(out_file)
os.remove(out_file)
cleaned = True
if not cleaned:
print 'Nothing to clean'
# Build the world.
if build:
should_refresh_python = (
want_refresh_python and
any(p.uses_python_env for p in packages_to_visit) and
not is_cross_compiling())
build_callback(packages_to_visit, should_refresh_python)
# Package it.
failed = []
succeeded = []
for pkg_def in packages_to_visit:
if pkg_def.disabled:
print_title('Skipping building disabled %s' % pkg_def.name)
continue
out_file = get_build_out_file(package_out_dir, pkg_def)
try:
info = None
if build:
info = build_pkg(cipd_exe, pkg_def, out_file, package_vars)
if upload:
if pkg_def.uses_python_env and not builder:
print ('Not uploading %s, since it uses a system Python enviornment '
'and that enviornment is only valid on builders.' % (
pkg_def.name,))
continue
info = upload_pkg(
cipd_exe,
out_file,
service_url,
tags,
pkg_def.update_latest_ref,
service_account_json)
assert info is not None
succeeded.append({'pkg_def_name': pkg_def.name, 'info': info})
except (BuildException, UploadException) as e:
failed.append({'pkg_def_name': pkg_def.name, 'error': str(e)})
print_title('Summary')
for d in failed:
print 'FAILED %s, see log above' % d['pkg_def_name']
for d in succeeded:
print '%s %s' % (d['info']['package'], d['info']['instance_id'])
if json_output:
with open(json_output, 'w') as f:
summary = {
'failed': failed,
'succeeded': succeeded,
'tags': sorted(tags),
'vars': package_vars,
}
json.dump(summary, f, sort_keys=True, indent=2, separators=(',', ': '))
return 1 if failed else 0
def build_infra(pkg_defs, should_refresh_python):
"""Builds infra.git multiverse.
Args:
pkg_defs: list of PackageDef instances for packages being built.
"""
if should_refresh_python:
print_title('Making sure python virtual environment is fresh')
run_python(
script=os.path.join(ROOT, 'bootstrap', 'bootstrap.py'),
args=[
'--deps_file',
os.path.join(ROOT, 'bootstrap', 'deps.pyl'),
os.path.join(ROOT, 'ENV'),
])
# Build all necessary go binaries.
build_go_code(os.path.join(ROOT, 'go'), pkg_defs)
def main(
args,
build_callback=build_infra,
go_workspace=os.path.join(ROOT, 'go'),
package_def_dir=os.path.join(ROOT, 'build', 'packages'),
package_out_dir=os.path.join(ROOT, 'build', 'out')):
parser = argparse.ArgumentParser(description='Builds infra CIPD packages')
parser.add_argument(
'yamls', metavar='YAML', type=str, nargs='*',
help='name of a file in build/packages/* with the package definition')
parser.add_argument(
'--upload', action='store_true', dest='upload', default=False,
help='upload packages into the repository')
parser.add_argument(
'--no-rebuild', action='store_false', dest='build', default=True,
help='when used with --upload means upload existing *.cipd files')
parser.add_argument(
'--builder', metavar='NAME', type=str,
help='Name of the CI buildbot builder that invokes this script.')
parser.add_argument(
'--service-url', metavar='URL', dest='service_url',
default=PACKAGE_REPO_SERVICE,
help='URL of the package repository service to use')
parser.add_argument(
'--service-account-json', metavar='PATH', dest='service_account_json',
help='path to credentials for service account to use')
parser.add_argument(
'--json-output', metavar='PATH', dest='json_output',
help='where to dump info about built package instances')
parser.add_argument(
'--tags', metavar='KEY:VALUE', type=str, dest='tags', nargs='*',
help='tags to attach to uploaded package instances')
parser.add_argument(
'--no-freshen-python-env', action='store_false', dest='refresh_python',
default=True, help=(
'skip freshening the python env. '+
'Only use if you know the env is clean.'))
args = parser.parse_args(args)
if not args.build and not args.upload:
parser.error('--no-rebuild doesn\'t make sense without --upload')
return run(
go_workspace,
build_callback,
args.builder,
package_def_dir,
package_out_dir,
[n + '.yaml' if not n.endswith('.yaml') else n for n in args.yamls],
args.build,
args.upload,
args.service_url,
args.tags or [],
args.service_account_json,
args.json_output,
args.refresh_python)
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))