Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(445)

Side by Side Diff: build/build.py

Issue 2095173002: Teach build.py to cross-compile go-based packages. (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: clarify that cross-compiled binaries are different Created 4 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
1 #!/usr/bin/env python 1 #!/usr/bin/env python
2 # Copyright 2015 The Chromium Authors. All rights reserved. 2 # Copyright 2015 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be 3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file. 4 # found in the LICENSE file.
5 5
6 """This script rebuilds Python & Go universes of infra.git multiverse and 6 """This script rebuilds Python & Go universes of infra.git multiverse and
7 invokes CIPD client to package and upload chunks of it to the CIPD repository as 7 invokes CIPD client to package and upload chunks of it to the CIPD repository as
8 individual packages. 8 individual packages.
9 9
10 See build/packages/*.yaml for definition of packages. 10 See build/packages/*.yaml for definition of packages and README.md for more
11 details.
11 """ 12 """
12 13
13 import argparse 14 import argparse
15 import contextlib
14 import glob 16 import glob
17 import hashlib
15 import json 18 import json
16 import os 19 import os
17 import platform 20 import platform
18 import shutil 21 import shutil
22 import socket
19 import subprocess 23 import subprocess
20 import sys 24 import sys
21 import tempfile 25 import tempfile
22 26
23 27
24 # Root of infra.git repository. 28 # Root of infra.git repository.
25 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 29 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26 30
27 # Root of infra gclient solution. 31 # Root of infra gclient solution.
28 GCLIENT_ROOT = os.path.dirname(ROOT) 32 GCLIENT_ROOT = os.path.dirname(ROOT)
29 33
30 # Where to upload packages to by default. 34 # Where to upload packages to by default.
31 PACKAGE_REPO_SERVICE = 'https://chrome-infra-packages.appspot.com' 35 PACKAGE_REPO_SERVICE = 'https://chrome-infra-packages.appspot.com'
32 36
33 # .exe on Windows. 37 # .exe on Windows.
34 EXE_SUFFIX = '.exe' if sys.platform == 'win32' else '' 38 EXE_SUFFIX = '.exe' if sys.platform == 'win32' else ''
35 39
36 40
37 class BuildException(Exception): 41 class BuildException(Exception):
38 """Raised on errors during package build step.""" 42 """Raised on errors during package build step."""
39 43
40 44
41 class UploadException(Exception): 45 class UploadException(Exception):
42 """Raised on errors during package upload step.""" 46 """Raised on errors during package upload step."""
43 47
44 48
49 class PackageDef(object):
50 """Represents parsed package *.yaml file."""
51
52 def __init__(self, path, pkg_def):
53 self.path = path
54 self.pkg_def = pkg_def
55
56 @property
57 def name(self):
58 """Returns name of YAML file (without the directory path and extension)."""
59 return os.path.splitext(os.path.basename(self.path))[0]
60
61 @property
62 def uses_python_env(self):
63 """Returns True if 'uses_python_env' in the YAML file is set."""
64 return self.pkg_def.get('uses_python_env')
nodir 2016/06/26 05:00:47 consider `return bool(...)` here like in other met
Vadim Sh. 2016/06/27 21:37:28 Done.
65
66 @property
67 def go_packages(self):
68 """Returns a list of Go packages that must be installed for this package."""
69 return self.pkg_def.get('go_packages') or []
70
71 def should_build(self, builder):
72 """Returns True if package should be built in the current environment.
73
74 Takes into account 'builders' and 'supports_cross_compilation' properties of
75 package definition file.
nodir 2016/06/26 05:00:47 the
Vadim Sh. 2016/06/27 21:37:29 Done.
76 """
77 # If '--builder' is not specified, ignore 'builders' property. Otherwise, if
78 # 'builders' YAML attribute it not empty, verify --builder is listed there.
79 builders = self.pkg_def.get('builders')
80 if builder and builders and builder not in builders:
81 return False
82
83 # If cross compiling, pick only packages that supports cross compilation.
nodir 2016/06/26 05:00:47 support
Vadim Sh. 2016/06/27 21:37:29 Done.
84 if is_cross_compiling():
85 return bool(self.pkg_def.get('supports_cross_compilation'))
86
87 return True
88
89
90 def is_cross_compiling():
91 """Returns true if using GOOS or GOARCH env vars.
nodir 2016/06/26 05:00:47 True
Vadim Sh. 2016/06/27 21:37:29 Done.
92
93 We also check at the start of the script that if one of them is used, then
94 the other is specified as well.
95 """
96 return bool(os.environ.get('GOOS')) or bool(os.environ.get('GOARCH'))
97
98
45 def run_python(script, args): 99 def run_python(script, args):
46 """Invokes a python script. 100 """Invokes a python script.
47 101
48 Raises: 102 Raises:
49 subprocess.CalledProcessError on non zero exit code. 103 subprocess.CalledProcessError on non zero exit code.
50 """ 104 """
51 print 'Running %s %s' % (script, ' '.join(args)) 105 print 'Running %s %s' % (script, ' '.join(args))
52 subprocess.check_call( 106 subprocess.check_call(
53 args=['python', '-u', script] + list(args), executable=sys.executable) 107 args=['python', '-u', script] + list(args), executable=sys.executable)
54 108
55 109
56 def run_cipd(go_workspace, cmd, args): 110 def run_cipd(cipd_exe, cmd, args):
57 """Invokes CIPD, parsing -json-output result. 111 """Invokes CIPD, parsing -json-output result.
58 112
59 Args: 113 Args:
60 go_workspace: path to 'infra/go' or 'infra_internal/go'. 114 cipd_exe: path to cipd client binary to run.
61 cmd: cipd subcommand to run. 115 cmd: cipd subcommand to run.
62 args: list of command line arguments to pass to the subcommand. 116 args: list of command line arguments to pass to the subcommand.
63 117
64 Returns: 118 Returns:
65 (Process exit code, parsed JSON output or None). 119 (Process exit code, parsed JSON output or None).
66 """ 120 """
67 temp_file = None 121 temp_file = None
68 try: 122 try:
69 fd, temp_file = tempfile.mkstemp(suffix='.json', prefix='cipd_%s' % cmd) 123 fd, temp_file = tempfile.mkstemp(suffix='.json', prefix='cipd_%s' % cmd)
70 os.close(fd) 124 os.close(fd)
71 125
72 cmd_line = [ 126 cmd_line = [cipd_exe, cmd, '-json-output', temp_file] + list(args)
73 os.path.join(go_workspace, 'bin', 'cipd' + EXE_SUFFIX),
74 cmd, '-json-output', temp_file,
75 ] + list(args)
76 127
77 print 'Running %s' % ' '.join(cmd_line) 128 print 'Running %s' % ' '.join(cmd_line)
78 exit_code = subprocess.call(args=cmd_line, executable=cmd_line[0]) 129 exit_code = subprocess.call(args=cmd_line, executable=cmd_line[0])
79 try: 130 try:
80 with open(temp_file, 'r') as f: 131 with open(temp_file, 'r') as f:
81 json_output = json.load(f) 132 json_output = json.load(f)
82 except (IOError, ValueError): 133 except (IOError, ValueError):
83 json_output = None 134 json_output = None
84 135
85 return exit_code, json_output 136 return exit_code, json_output
86 finally: 137 finally:
87 try: 138 try:
88 if temp_file: 139 if temp_file:
89 os.remove(temp_file) 140 os.remove(temp_file)
90 except OSError: 141 except OSError:
91 pass 142 pass
92 143
93 144
94 def print_title(title): 145 def print_title(title):
95 """Pretty prints a banner to stdout.""" 146 """Pretty prints a banner to stdout."""
96 sys.stdout.flush() 147 sys.stdout.flush()
97 sys.stderr.flush() 148 sys.stderr.flush()
98 print 149 print
99 print '-' * 80 150 print '-' * 80
100 print title 151 print title
101 print '-' * 80 152 print '-' * 80
102 153
103 154
104 def build_go(go_workspace, packages): 155 def print_go_step_title(title):
105 """Bootstraps go environment and rebuilds (and installs) Go packages. 156 """Same as 'print_title', but also appends values of GOOS and GOARCH."""
157 if is_cross_compiling():
158 title += '\n' + '-' * 80
159 title += '\n GOOS=%s' % os.environ['GOOS']
160 title += '\n GOARCH=%s' % os.environ['GOARCH']
161 if 'GOARM' in os.environ:
162 title += '\n GOARM=%s' % os.environ['GOARM']
163 print_title(title)
106 164
107 Compiles and installs packages into default GOBIN, which is <path>/go/bin/ 165
108 (it is setup by go/env.py and depends on what workspace is used). 166 @contextlib.contextmanager
167 def hacked_workspace(go_workspace, goos=None, goarch=None):
168 """Symlinks Go workspace into new root, modifies os.environ.
169
170 Go toolset embeds absolute paths to *.go files into the executable. Use
171 symlink with stable path to make executables independent of checkout path.
109 172
110 Args: 173 Args:
111 go_workspace: path to 'infra/go' or 'infra_internal/go'. 174 go_workspace: path to 'infra/go' or 'infra_internal/go'.
112 packages: list of packages to build (can include '...' patterns). 175 goos: if set, overrides GOOS environment variable (removes it if '').
176 goarch: if set, overrides GOARCH environment variable (removes it if '').
177
178 Yields:
179 Path where go_workspace is symlinked to.
113 """ 180 """
114 print_title('Compiling Go code: %s' % ', '.join(packages))
115
116 # Go toolchain embeds absolute paths to *.go files into the executable. Use
117 # symlink with stable path to make executables independent of checkout path.
118 new_root = None 181 new_root = None
119 new_workspace = go_workspace 182 new_workspace = go_workspace
120 if sys.platform != 'win32': 183 if sys.platform != 'win32':
121 new_root = '/tmp/_chrome_infra_build' 184 new_root = '/tmp/_chrome_infra_build'
122 if os.path.exists(new_root): 185 if os.path.exists(new_root):
123 assert os.path.islink(new_root) 186 assert os.path.islink(new_root)
124 os.remove(new_root) 187 os.remove(new_root)
125 os.symlink(GCLIENT_ROOT, new_root) 188 os.symlink(GCLIENT_ROOT, new_root)
126 rel = os.path.relpath(go_workspace, GCLIENT_ROOT) 189 rel = os.path.relpath(go_workspace, GCLIENT_ROOT)
127 assert not rel.startswith('..'), rel 190 assert not rel.startswith('..'), rel
128 new_workspace = os.path.join(new_root, rel) 191 new_workspace = os.path.join(new_root, rel)
129 192
130 # Remove any stale binaries and libraries. 193 orig_environ = os.environ.copy()
131 shutil.rmtree(os.path.join(new_workspace, 'bin'), ignore_errors=True)
132 shutil.rmtree(os.path.join(new_workspace, 'pkg'), ignore_errors=True)
133 194
134 # Recompile ('-a'). 195 if goos is not None:
196 if goos == '':
197 os.environ.pop('GOOS', None)
198 else:
199 os.environ['GOOS'] = goos
200 if goarch is not None:
201 if goarch == '':
202 os.environ.pop('GOARCH', None)
203 else:
204 os.environ['GOARCH'] = goarch
205
206 # Make sure we build ARMv6 code even if the host is ARMv7. See the comment in
207 # get_host_package_vars for reasons why. Also explicitly set GOARM to 6 when
208 # cross-compiling (it should be '6' in this case by default anyway).
209 plat = platform.machine().lower()
210 if plat.startswith('arm') or os.environ.get('GOARCH') == 'arm':
211 os.environ['GOARM'] = '6'
212 else:
213 os.environ.pop('GOARM', None)
214
135 try: 215 try:
216 yield new_workspace
217 finally:
218 # Apparently 'os.environ = orig_environ' doesn't actually modify process
219 # environment, only modifications of os.environ object itself do.
220 for k, v in orig_environ.iteritems():
221 os.environ[k] = v
222 for k in [key for key in os.environ if key not in orig_environ]:
nodir 2016/06/26 05:00:47 fwiu this is complicated because you don't want to
Vadim Sh. 2016/06/27 21:37:28 Done.
223 os.environ.pop(k)
224 if new_root:
225 os.remove(new_root)
226
227
228 def bootstrap_go_toolset(go_workspace):
229 """Makes sure go toolset is installed and returns its 'go env' environment.
230
231 Used to verify that our platform detection in get_host_package_vars() matches
232 the Go toolset being used.
233 """
234 with hacked_workspace(go_workspace) as new_workspace:
235 print_go_step_title('Making sure Go toolset is installed')
236 # env.py does the actual job of bootstrapping if the toolset is missing.
237 output = subprocess.check_output(
238 args=[
239 'python', '-u', os.path.join(new_workspace, 'env.py'),
240 'go', 'env',
241 ],
242 executable=sys.executable)
243 print 'Go environ:'
244 print output.strip()
245 env = {}
246 for line in output.splitlines():
247 k, _, v = line.partition('=')
248 env[k] = v.strip('"')
nodir 2016/06/26 05:00:47 strictly speaking v may end with \" Maybe do that
Vadim Sh. 2016/06/27 21:37:28 Done. It can also be different on Windows (and pla
249 return env
250
251
252 def run_go_install(go_workspace, packages, goos=None, goarch=None):
253 """Rebuilds (and installs) Go packages into GOBIN via 'go install ...'.
254
255 Compiles and installs packages into default GOBIN, which is <go_workspace>/bin
256 (it is setup by go/env.py).
257
258 Args:
259 go_workspace: path to 'infra/go' or 'infra_internal/go'.
260 packages: list of packages to build (can include '...' patterns).
261 goos: if set, overrides GOOS environment variable (removes it if '').
262 goarch: if set, overrides GOARCH environment variable (removes it if '').
263 """
264 with hacked_workspace(go_workspace, goos, goarch) as new_workspace:
265 print_go_step_title('Building:\n %s' % '\n '.join(packages))
136 subprocess.check_call( 266 subprocess.check_call(
137 args=[ 267 args=[
138 'python', '-u', os.path.join(new_workspace, 'env.py'), 268 'python', '-u', os.path.join(new_workspace, 'env.py'),
139 'go', 'install', '-a', '-v', 269 'go', 'install', '-a', '-v',
140 ] + list(packages), 270 ] + list(packages),
nodir 2016/06/26 05:00:47 this may hit cmd length limit on win
Vadim Sh. 2016/06/27 21:37:28 Acknowledged. Not a problem in the near future tho
141 executable=sys.executable, 271 executable=sys.executable,
142 stderr=subprocess.STDOUT) 272 stderr=subprocess.STDOUT)
143 finally:
144 if new_root:
145 os.remove(new_root)
146 273
147 274
148 def enumerate_packages_to_build(package_def_dir, package_def_files=None): 275 def run_go_build(go_workspace, package, output, goos=None, goarch=None):
149 """Returns a list of absolute paths to files in build/packages/*.yaml. 276 """Builds single Go package (rebuilding all its dependencies).
150 277
151 Args: 278 Args:
279 package: package to build.
nodir 2016/06/26 05:00:48 document go_workspace
Vadim Sh. 2016/06/27 21:37:28 Done.
280 output: where to put the resulting binary.
281 goos: if set, overrides GOOS environment variable (removes it if '').
282 goarch: if set, overrides GOARCH environment variable (removes it if '').
283 """
284 with hacked_workspace(go_workspace, goos, goarch) as new_workspace:
285 print_go_step_title('Building %s' % package)
286 subprocess.check_call(
287 args=[
288 'python', '-u', os.path.join(new_workspace, 'env.py'),
289 'go', 'build', '-a', '-v', '-o', output, package,
290 ],
291 executable=sys.executable,
292 stderr=subprocess.STDOUT)
293
294
295 def build_go_code(go_workspace, pkg_defs):
296 """Builds and installs all Go packages used by the given PackageDefs.
297
298 Understands GOOS and GOARCH and uses slightly different build strategy when
299 cross-compiling. In the end <go_workspace>/bin will have all built binaries,
300 and only them (regardless of whether we are cross compiling or not).
301
302 Args:
303 go_workspace: path to 'infra/go' or 'infra_internal/go'.
304 pkg_defs: list of PackageDef objects that define what to build.
305 """
306 # Make sure there are no stale files in the workspace.
307 shutil.rmtree(os.path.join(go_workspace, 'bin'), ignore_errors=True)
nodir 2016/06/26 05:00:47 is this necessary? fwiu if I want infra/go/bin/foo
Vadim Sh. 2016/06/27 21:37:29 Using "go clean" now and not passing '-a' ("rebuil
308 os.mkdir(os.path.join(go_workspace, 'bin'))
309 shutil.rmtree(os.path.join(go_workspace, 'pkg'), ignore_errors=True)
310 os.mkdir(os.path.join(go_workspace, 'pkg'))
311
312 # Grab a set of all go packages we need to build and install into GOBIN.
313 to_install = []
314 for p in pkg_defs:
315 to_install.extend(p.go_packages)
316 to_install = sorted(set(to_install))
317 if not to_install:
318 return
319
320 if not is_cross_compiling():
321 # If not cross compiling, build all Go code in a single "go install" step,
322 # it's faster that way. We can't do that when cross-compiling, since
323 # 'go install' isn't supposed to be used for cross-compilation and the
324 # toolset actively complains with "go install: cannot install cross-compiled
325 # binaries when GOBIN is set".
326 run_go_install(go_workspace, to_install)
327 else:
328 # Build packages one by one and put the resulting binaries into GOBIN, as if
329 # they were installed there. It's where the rest of the build.py code
330 # expects them to be (see also 'root' property in package definition YAMLs).
331 # It's much slower compared to a single 'go install' since will rebuild all
332 # dependent libraries at each 'go build' invocation.
333 #
334 # TODO(vadimsh): Use 'go install std' to prebuild std lib for the target
335 # platform. Unfortunately '-a' flag we pass in run_go_build causes the
336 # compiler to rebuild stdlib anyway, even if it is already installed. We use
337 # '-a' as a precaution against stale *.a in .vendor/ and other GOPATHs,
338 # since Go for some reason fails to rebuild them sometimes.
339 go_bin = os.path.join(go_workspace, 'bin')
340 exe_suffix = get_target_package_vars()['exe_suffix']
341 for pkg in to_install:
342 name = pkg[pkg.rfind('/')+1:]
343 run_go_build(go_workspace, pkg, os.path.join(go_bin, name + exe_suffix))
344
345
346 def enumerate_packages(py_venv, package_def_dir, package_def_files):
347 """Returns a list PackageDef instances for files in build/packages/*.yaml.
348
349 Args:
350 py_env: path to python ENV where to look for YAML parser.
152 package_def_dir: path to build/packages dir to search for *.yaml. 351 package_def_dir: path to build/packages dir to search for *.yaml.
153 package_def_files: optional list of filenames to limit results to. 352 package_def_files: optional list of filenames to limit results to.
154 353
155 Returns: 354 Returns:
156 List of absolute paths to *.yaml files under packages_dir. 355 List of PackageDef instances parsed from *.yaml files under packages_dir.
157 """ 356 """
158 # All existing package by default. 357 paths = []
159 if not package_def_files: 358 if not package_def_files:
160 return sorted(glob.glob(os.path.join(package_def_dir, '*.yaml'))) 359 # All existing package by default.
161 paths = [] 360 paths = glob.glob(os.path.join(package_def_dir, '*.yaml'))
162 for name in package_def_files: 361 else:
163 abs_path = os.path.join(package_def_dir, name) 362 # Otherwise pick only the ones in 'package_def_files' list.
164 if not os.path.isfile(abs_path): 363 for name in package_def_files:
165 raise BuildException('No such package definition file: %s' % name) 364 abs_path = os.path.join(package_def_dir, name)
nodir 2016/06/26 05:00:47 not necessarily absolute though
Vadim Sh. 2016/06/27 21:37:28 Done.
166 paths.append(abs_path) 365 if not os.path.isfile(abs_path):
167 return sorted(paths) 366 raise BuildException('No such package definition file: %s' % name)
367 paths.append(abs_path)
368 return [PackageDef(p, read_yaml(py_venv, p)) for p in sorted(paths)]
168 369
169 370
170 def read_yaml(py_venv, path): 371 def read_yaml(py_venv, path):
171 """Returns content of YAML file as python dict.""" 372 """Returns content of YAML file as python dict."""
172 # YAML lib is in venv, not activated here. Go through hoops. 373 # YAML lib is in venv, not activated here. Go through hoops.
374 # TODO(vadimsh): Doesn't work on ARM, since we have no working infra_python
375 # venv there. Replace this hack with vendored pure-python PyYAML.
173 oneliner = ( 376 oneliner = (
174 'import json, sys, yaml; ' 377 'import json, sys, yaml; '
175 'json.dump(yaml.safe_load(sys.stdin), sys.stdout)') 378 'json.dump(yaml.safe_load(sys.stdin), sys.stdout)')
176 if sys.platform == 'win32': 379 if sys.platform == 'win32':
177 python_venv_path = ('Scripts', 'python.exe') 380 python_venv_path = ('Scripts', 'python.exe')
178 else: 381 else:
179 python_venv_path = ('bin', 'python') 382 python_venv_path = ('bin', 'python')
180 executable = os.path.join(py_venv, *python_venv_path) 383 executable = os.path.join(py_venv, *python_venv_path)
181 env = os.environ.copy() 384 env = os.environ.copy()
182 env.pop('PYTHONPATH', None) 385 env.pop('PYTHONPATH', None)
183 proc = subprocess.Popen( 386 proc = subprocess.Popen(
184 [executable, '-c', oneliner], 387 [executable, '-c', oneliner],
185 executable=executable, 388 executable=executable,
186 stdin=subprocess.PIPE, 389 stdin=subprocess.PIPE,
187 stdout=subprocess.PIPE, 390 stdout=subprocess.PIPE,
188 env=env) 391 env=env)
189 with open(path, 'r') as f: 392 with open(path, 'r') as f:
190 out, _ = proc.communicate(f.read()) 393 out, _ = proc.communicate(f.read())
191 if proc.returncode: 394 if proc.returncode:
192 raise BuildException('Failed to parse YAML at %s' % path) 395 raise BuildException('Failed to parse YAML at %s' % path)
193 return json.loads(out) 396 return json.loads(out)
194 397
195 398
196 def should_process_on_builder(pkg_def_file, py_venv, builder):
197 """Returns True if package should be processed by current CI builder."""
198 if not builder:
199 return True
200 builders = read_yaml(py_venv, pkg_def_file).get('builders')
201 return not builders or builder in builders
202
203
204 def get_package_vars(): 399 def get_package_vars():
205 """Returns a dict with variables that describe the current environment. 400 """Returns a dict with variables that describe the package target environment.
206 401
207 Variables can be referenced in the package definition YAML as 402 Variables can be referenced in the package definition YAML as
208 ${variable_name}. It allows to reuse exact same definition file for similar 403 ${variable_name}. It allows to reuse exact same definition file for similar
209 packages (e.g. packages with same cross platform binary, but for different 404 packages (e.g. packages with same cross platform binary, but for different
210 platforms). 405 platforms).
406
407 If running in cross-compilation mode, uses GOOS and GOARCH to figure out the
408 target platform instead of examining the host environment.
409 """
410 if is_cross_compiling():
411 return get_target_package_vars()
412 return get_host_package_vars()
413
414
415 def get_target_package_vars():
416 """Returns a dict with variables that describe cross compilation target env.
nodir 2016/06/26 05:00:48 cross-compilation here and other places
Vadim Sh. 2016/06/27 21:37:28 Done.
417
418 Examines os.environ for GOOS, GOARCH and GOARM.
419
420 It contains only 'platform' and 'exe_suffix'.
nodir 2016/06/26 05:00:47 s/It/Returned dict/
Vadim Sh. 2016/06/27 21:37:28 Done.
421 """
422 assert is_cross_compiling()
423 goos = os.environ['GOOS']
424 goarch = os.environ['GOARCH']
425
426 if goarch not in ('386', 'amd64', 'arm'):
427 raise BuildException('Unsupported GOARCH %s' % goarch)
428
429 # There are many ARMs, pick the concrete instruction set. 'v6' is the default,
430 # don't try to support other variants for now.
431 #
432 # See https://golang.org/doc/install/source#environment.
433 if goarch == 'arm':
434 goarm = os.environ.get('GOARM', '6')
435 if goarm == '6':
nodir 2016/06/26 05:00:47 invert and remove else
Vadim Sh. 2016/06/27 21:37:28 Done.
436 arch = 'armv6l'
437 else:
438 raise BuildException('Unsupported GOARM value %s' % goarm)
439 else:
440 arch = goarch
441
442 # We use 'mac' instead of 'darwin'.
443 if goos == 'darwin':
444 goos = 'mac'
445
446 return {
447 'exe_suffix': '.exe' if goos == 'windows' else '',
448 'platform': '%s-%s' % (goos, arch),
449 }
450
451
452 def get_host_package_vars():
453 """Returns a dict with variables that describe the current host environment.
454
455 The returned platform may not match the machine environment exactly, but it is
456 compatible with it.
457
458 For example, on ARMv7 machines we claim that we are in fact running ARMv6
459 (which is subset of ARMv7), since we don't really care about v7 over v6
460 difference and want to reduce the variability in supported architectures
461 instead.
462
463 Similarly, if running on 64-bit Linux with 32-bit user space (based on python
464 interpreter bitness), we claim that machine is 32-bit, since most 32-bit Linux
465 Chrome Infra bots are in fact running 64-bit kernels with 32-bit userlands.
211 """ 466 """
212 # linux, mac or windows. 467 # linux, mac or windows.
213 platform_variant = { 468 platform_variant = {
214 'darwin': 'mac', 469 'darwin': 'mac',
215 'linux2': 'linux', 470 'linux2': 'linux',
216 'win32': 'windows', 471 'win32': 'windows',
217 }.get(sys.platform) 472 }.get(sys.platform)
218 if not platform_variant: 473 if not platform_variant:
219 raise ValueError('Unknown OS: %s' % sys.platform) 474 raise ValueError('Unknown OS: %s' % sys.platform)
220 475
(...skipping 12 matching lines...) Expand all
233 else: 488 else:
234 raise ValueError('Unknown OS: %s' % sys.platform) 489 raise ValueError('Unknown OS: %s' % sys.platform)
235 490
236 # amd64, 386, etc. 491 # amd64, 386, etc.
237 platform_arch = { 492 platform_arch = {
238 'amd64': 'amd64', 493 'amd64': 'amd64',
239 'i386': '386', 494 'i386': '386',
240 'i686': '386', 495 'i686': '386',
241 'x86': '386', 496 'x86': '386',
242 'x86_64': 'amd64', 497 'x86_64': 'amd64',
498 'armv6l': 'armv6l',
499 'armv7l': 'armv6l', # we prefer to use older instruction set for builds
243 }.get(platform.machine().lower()) 500 }.get(platform.machine().lower())
244 if not platform_arch: 501 if not platform_arch:
245 raise ValueError('Unknown machine arch: %s' % platform.machine()) 502 raise ValueError('Unknown machine arch: %s' % platform.machine())
246 503
504 # Most 32-bit Linux Chrome Infra bots are in fact running 64-bit kernel with
505 # 32-bit userland. Detect this case (based on bitness of the python
506 # interpreter) and report the bot as '386'.
507 if (platform_variant == 'linux' and
508 platform_arch == 'amd64' and
509 sys.maxsize == (2 ** 31) - 1):
510 platform_arch = '386'
511
247 return { 512 return {
248 # e.g. '.exe' or ''. 513 # e.g. '.exe' or ''.
249 'exe_suffix': EXE_SUFFIX, 514 'exe_suffix': EXE_SUFFIX,
250 # e.g. 'ubuntu14_04' or 'mac10_9' or 'win6_1'. 515 # e.g. 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
251 'os_ver': os_ver, 516 'os_ver': os_ver,
252 # e.g. 'linux-amd64' 517 # e.g. 'linux-amd64'
253 'platform': '%s-%s' % (platform_variant, platform_arch), 518 'platform': '%s-%s' % (platform_variant, platform_arch),
254 # e.g. '27' (dots are not allowed in package names). 519 # e.g. '27' (dots are not allowed in package names).
255 'python_version': '%s%s' % sys.version_info[:2], 520 'python_version': '%s%s' % sys.version_info[:2],
256 } 521 }
257 522
258 523
259 def build_pkg(go_workspace, pkg_def_file, out_file, package_vars): 524 def build_pkg(cipd_exe, pkg_def, out_file, package_vars):
260 """Invokes CIPD client to build a package. 525 """Invokes CIPD client to build a package.
261 526
262 Args: 527 Args:
263 go_workspace: path to 'infra/go' or 'infra_internal/go'. 528 cipd_exe: path to cipd client binary to use.
264 pkg_def_file: path to *.yaml file with package definition. 529 pkg_def: instance of PackageDef representing this package.
265 out_file: where to store the built package. 530 out_file: where to store the built package.
266 package_vars: dict with variables to pass as -pkg-var to cipd. 531 package_vars: dict with variables to pass as -pkg-var to cipd.
267 532
268 Returns: 533 Returns:
269 {'package': <name>, 'instance_id': <sha1>} 534 {'package': <name>, 'instance_id': <sha1>}
270 535
271 Raises: 536 Raises:
272 BuildException on error. 537 BuildException on error.
273 """ 538 """
274 print_title('Building: %s' % os.path.basename(pkg_def_file)) 539 print_title('Building: %s' % os.path.basename(out_file))
275 540
276 # Make sure not stale output remains. 541 # Make sure not stale output remains.
277 if os.path.isfile(out_file): 542 if os.path.isfile(out_file):
278 os.remove(out_file) 543 os.remove(out_file)
279 544
280 # Build the package. 545 # Build the package.
281 args = ['-pkg-def', pkg_def_file] 546 args = ['-pkg-def', pkg_def.path]
282 for k, v in sorted(package_vars.items()): 547 for k, v in sorted(package_vars.items()):
283 args.extend(['-pkg-var', '%s:%s' % (k, v)]) 548 args.extend(['-pkg-var', '%s:%s' % (k, v)])
284 args.extend(['-out', out_file]) 549 args.extend(['-out', out_file])
285 exit_code, json_output = run_cipd(go_workspace, 'pkg-build', args) 550 exit_code, json_output = run_cipd(cipd_exe, 'pkg-build', args)
286 if exit_code: 551 if exit_code:
287 print 552 print
288 print >> sys.stderr, 'FAILED! ' * 10 553 print >> sys.stderr, 'FAILED! ' * 10
289 raise BuildException('Failed to build the CIPD package, see logs') 554 raise BuildException('Failed to build the CIPD package, see logs')
290 555
291 # Expected result is {'package': 'name', 'instance_id': 'sha1'} 556 # Expected result is {'package': 'name', 'instance_id': 'sha1'}
292 info = json_output['result'] 557 info = json_output['result']
293 print '%s %s' % (info['package'], info['instance_id']) 558 print '%s %s' % (info['package'], info['instance_id'])
294 return info 559 return info
295 560
296 561
297 def upload_pkg(go_workspace, pkg_file, service_url, tags, service_account): 562 def upload_pkg(cipd_exe, pkg_file, service_url, tags, service_account):
298 """Uploads existing *.cipd file to the storage and tags it. 563 """Uploads existing *.cipd file to the storage and tags it.
299 564
300 Args: 565 Args:
301 go_workspace: path to 'infra/go' or 'infra_internal/go'. 566 cipd_exe: path to cipd client binary to use.
302 pkg_file: path to *.cipd file to upload. 567 pkg_file: path to *.cipd file to upload.
303 service_url: URL of a package repository service. 568 service_url: URL of a package repository service.
304 tags: a list of tags to attach to uploaded package instance. 569 tags: a list of tags to attach to uploaded package instance.
305 service_account: path to *.json file with service account to use. 570 service_account: path to *.json file with service account to use.
306 571
307 Returns: 572 Returns:
308 {'package': <name>, 'instance_id': <sha1>} 573 {'package': <name>, 'instance_id': <sha1>}
309 574
310 Raises: 575 Raises:
311 UploadException on error. 576 UploadException on error.
312 """ 577 """
313 print_title('Uploading: %s' % os.path.basename(pkg_file)) 578 print_title('Uploading: %s' % os.path.basename(pkg_file))
314 579
315 args = ['-service-url', service_url] 580 args = ['-service-url', service_url]
316 for tag in sorted(tags): 581 for tag in sorted(tags):
317 args.extend(['-tag', tag]) 582 args.extend(['-tag', tag])
318 args.extend(['-ref', 'latest']) 583 args.extend(['-ref', 'latest'])
319 if service_account: 584 if service_account:
320 args.extend(['-service-account-json', service_account]) 585 args.extend(['-service-account-json', service_account])
321 args.append(pkg_file) 586 args.append(pkg_file)
322 exit_code, json_output = run_cipd(go_workspace, 'pkg-register', args) 587 exit_code, json_output = run_cipd(cipd_exe, 'pkg-register', args)
323 if exit_code: 588 if exit_code:
324 print 589 print
325 print >> sys.stderr, 'FAILED! ' * 10 590 print >> sys.stderr, 'FAILED! ' * 10
326 raise UploadException('Failed to upload the CIPD package, see logs') 591 raise UploadException('Failed to upload the CIPD package, see logs')
327 info = json_output['result'] 592 info = json_output['result']
328 print '%s %s' % (info['package'], info['instance_id']) 593 print '%s %s' % (info['package'], info['instance_id'])
329 return info 594 return info
330 595
331 596
597 def build_cipd_client(go_workspace, out_dir):
598 """Builds cipd client binary for the host platform.
599
600 Ignores GOOS and GOARCH env vars. Puts the client binary into
601 '<out_dir>/.cipd_client/cipd_<digest>'.
602
603 This binary is used by build.py itself and later by test_packages.py.
604
605 Args:
606 go_workspace: path to Go workspace root (contains 'env.py', 'src', etc).
607 out_dir: build output directory, will be used to store the binary.
608
609 Returns:
610 Path to the built binary.
611 """
612 # To avoid rebuilding cipd client all the time, we cache it in out/*, using
613 # a combination of DEPS+deps.lock+bootstrap.py as a cache key (they define
614 # exact set of sources used to build the cipd binary).
615 #
616 # We can't just use the client in infra.git/cipd/* because it is built by this
617 # script itself: it introduced bootstrap dependency cycle in case we need to
618 # add a new platform or if we wipe cipd backend storage.
619 seed_paths = [
620 os.path.join(ROOT, 'DEPS'),
621 os.path.join(ROOT, 'go', 'deps.lock'),
622 os.path.join(ROOT, 'go', 'bootstrap.py'),
623 ]
624 digest = hashlib.sha1()
625 for p in seed_paths:
626 with open(p, 'rb') as f:
627 digest.update(f.read())
628 cache_key = digest.hexdigest()[:20]
629
630 # Already have it?
631 cipd_out_dir = os.path.join(out_dir, '.cipd_client')
632 cipd_exe = os.path.join(cipd_out_dir, 'cipd_%s%s' % (cache_key, EXE_SUFFIX))
633 if os.path.exists(cipd_exe):
634 return cipd_exe
635
636 # Nuke all previous copies, make sure out_dir exists.
637 if os.path.exists(cipd_out_dir):
638 for p in glob.glob(os.path.join(cipd_out_dir, 'cipd_*')):
639 os.remove(p)
640 else:
641 os.makedirs(cipd_out_dir)
642
643 # Build cipd client binary for the host platform.
644 run_go_build(
645 go_workspace,
646 package='github.com/luci/luci-go/client/cmd/cipd',
647 output=cipd_exe,
648 goos='',
649 goarch='')
650
651 return cipd_exe
652
653
654 def get_build_out_file(package_out_dir, pkg_def):
655 """Returns a path where to put built *.cipd package file.
656
657 Args:
658 package_out_dir: root directory where to put *.cipd files.
659 pkg_def: instance of PackageDef being built.
660 """
661 # When cross-compiling, append a suffix to package file name to indicate that
662 # it's for foreign platform.
663 sfx = ''
664 if is_cross_compiling():
665 sfx = '+' + get_target_package_vars()['platform']
666 return os.path.join(package_out_dir, pkg_def.name + sfx + '.cipd')
667
668
332 def run( 669 def run(
333 py_venv, 670 py_venv,
334 go_workspace, 671 go_workspace,
335 build_callback, 672 build_callback,
336 builder, 673 builder,
337 package_def_dir, 674 package_def_dir,
338 package_out_dir, 675 package_out_dir,
339 package_def_files, 676 package_def_files,
340 build, 677 build,
341 upload, 678 upload,
342 service_url, 679 service_url,
343 tags, 680 tags,
344 service_account_json, 681 service_account_json,
345 json_output): 682 json_output):
346 """Rebuild python and Go universes and CIPD packages. 683 """Rebuilds python and Go universes and CIPD packages.
347 684
348 Args: 685 Args:
349 py_venv: path to 'infra/ENV' or 'infra_internal/ENV'. 686 py_venv: path to 'infra/ENV' or 'infra_internal/ENV'.
350 go_workspace: path to 'infra/go' or 'infra_internal/go'. 687 go_workspace: path to 'infra/go' or 'infra_internal/go'.
351 build_callback: called to build binaries, virtual environment, etc. 688 build_callback: called to build binaries, virtual environment, etc.
352 builder: name of CI buildbot builder that invoked the script. 689 builder: name of CI buildbot builder that invoked the script.
353 package_def_dir: path to build/packages dir to search for *.yaml. 690 package_def_dir: path to build/packages dir to search for *.yaml.
354 package_out_dir: where to put built packages. 691 package_out_dir: where to put built packages.
355 package_def_files: names of *.yaml files in package_def_dir or [] for all. 692 package_def_files: names of *.yaml files in package_def_dir or [] for all.
356 build: False to skip building packages (valid only when upload==True). 693 build: False to skip building packages (valid only when upload==True).
357 upload: True to also upload built packages, False just to build them. 694 upload: True to also upload built packages, False just to build them.
358 service_url: URL of a package repository service. 695 service_url: URL of a package repository service.
359 tags: a list of tags to attach to uploaded package instances. 696 tags: a list of tags to attach to uploaded package instances.
360 service_account_json: path to *.json service account credential. 697 service_account_json: path to *.json service account credential.
361 json_output: path to *.json file to write info about built packages to. 698 json_output: path to *.json file to write info about built packages to.
362 699
363 Returns: 700 Returns:
364 0 on success, 1 or error. 701 0 on success, 1 or error.
365 """ 702 """
366 assert build or upload, 'Both build and upload are False, nothing to do' 703 assert build or upload, 'Both build and upload are False, nothing to do'
367 704
368 # Remove stale output so that test_packages.py do not test old files when 705 # We need both GOOS and GOARCH or none.
369 # invoked without arguments. 706 if is_cross_compiling():
370 if build: 707 if not os.environ.get('GOOS') or not os.environ.get('GOARCH'):
371 for path in glob.glob(os.path.join(package_out_dir, '*.cipd')): 708 print >> sys.stderr, (
372 os.remove(path) 709 'When cross-compiling both GOOS and GOARCH environment variables '
710 'must be set.')
711 return 1
373 712
nodir 2016/06/26 05:00:47 check that GOARM env var is '6' or None
Vadim Sh. 2016/06/27 21:37:28 Done.
374 packages_to_build = [ 713 # Append tags related to the build host. They are especially important when
375 p for p in enumerate_packages_to_build(package_def_dir, package_def_files) 714 # cross-compiling: cross-compiled packages can be identified by comparing the
376 if should_process_on_builder(p, py_venv, builder) 715 # platform in the package name with value of 'build_host_platform' tag.
377 ] 716 tags = list(tags)
717 host_vars = get_host_package_vars()
718 tags.append('build_host_fqdn:' + socket.getfqdn())
nodir 2016/06/26 05:00:47 maybe only first component? full name may contain
Vadim Sh. 2016/06/27 21:37:29 Done.
719 tags.append('build_host_platform:' + host_vars['platform'])
720 tags.append('build_host_os_ver:' + host_vars['os_ver'])
721
722 all_packages = enumerate_packages(py_venv, package_def_dir, package_def_files)
723 packages_to_build = [p for p in all_packages if p.should_build(builder)]
378 724
379 print_title('Overview') 725 print_title('Overview')
380 print 'Service URL: %s' % service_url 726 if upload:
381 print 727 print 'Service URL: %s' % service_url
728 print
382 if builder: 729 if builder:
383 print 'Package definition files to process on %s:' % builder 730 print 'Package definition files to process on %s:' % builder
384 else: 731 else:
385 print 'Package definition files to process:' 732 print 'Package definition files to process:'
386 for pkg_def_file in packages_to_build: 733 for pkg_def in packages_to_build:
387 print ' %s' % os.path.basename(pkg_def_file) 734 print ' %s' % pkg_def.name
388 if not packages_to_build: 735 if not packages_to_build:
389 print ' <none>' 736 print ' <none>'
390 print 737 print
391 print 'Variables to pass to CIPD:' 738 print 'Variables to pass to CIPD:'
392 package_vars = get_package_vars() 739 package_vars = get_package_vars()
393 for k, v in sorted(package_vars.items()): 740 for k, v in sorted(package_vars.items()):
394 print ' %s = %s' % (k, v) 741 print ' %s = %s' % (k, v)
395 if tags: 742 if upload and tags:
396 print 743 print
397 print 'Tags to attach to uploaded packages:' 744 print 'Tags to attach to uploaded packages:'
398 for tag in sorted(tags): 745 for tag in sorted(tags):
399 print ' %s' % tag 746 print ' %s' % tag
400 if not packages_to_build: 747 if not packages_to_build:
401 print 748 print
402 print 'Nothing to do.' 749 print 'Nothing to do.'
403 return 0 750 return 0
404 751
752 # Remove old build artifacts to avoid stale files in case the script crashes
753 # for some reason.
754 if build:
755 print_title('Cleaning %s' % package_out_dir)
756 if not os.path.exists(package_out_dir):
757 os.makedirs(package_out_dir)
758 cleaned = False
759 for pkg_def in packages_to_build:
760 out_file = get_build_out_file(package_out_dir, pkg_def)
761 if os.path.exists(out_file):
762 print 'Removing stale %s' % os.path.basename(out_file)
763 os.remove(out_file)
764 cleaned = True
765 if not cleaned:
766 print 'Nothing to clean'
767
768 # Make sure we have a Go toolset and it matches the host platform we detected
769 # in get_host_package_vars(). Otherwise we may end up uploading wrong binaries
770 # under host platform CIPD package suffix. It's important on Linux with 64-bit
771 # kernel and 32-bit userland (we must use 32-bit Go in that case, even if
772 # 64-bit Go works too).
773 go_env = bootstrap_go_toolset(go_workspace)
774 expected_arch = host_vars['platform'].split('-')[1]
775 if go_env['GOHOSTARCH'] != expected_arch:
nodir 2016/06/26 05:00:47 GOHOST* vars are hardly documented. Can minimally
Vadim Sh. 2016/06/27 21:37:29 IMHO, it's pretty obvious what they mean.
776 print >> sys.stderr, (
777 'Go toolset GOHOSTARCH (%s) doesn\'t match expected architecture (%s)' %
778 (go_env['GOHOSTARCH'], expected_arch))
779 return 1
780
781 # Build the cipd client needed later to build or upload packages.
782 cipd_exe = build_cipd_client(go_workspace, package_out_dir)
783
405 # Build the world. 784 # Build the world.
406 if build: 785 if build:
407 build_callback() 786 build_callback(packages_to_build)
408 787
409 # Package it. 788 # Package it.
410 failed = [] 789 failed = []
411 succeeded = [] 790 succeeded = []
412 for pkg_def_file in packages_to_build: 791 for pkg_def in packages_to_build:
413 # path/name.yaml -> out/name.cipd. 792 out_file = get_build_out_file(package_out_dir, pkg_def)
414 name = os.path.splitext(os.path.basename(pkg_def_file))[0]
415 out_file = os.path.join(package_out_dir, name + '.cipd')
416 try: 793 try:
417 info = None 794 info = None
418 if build: 795 if build:
419 info = build_pkg(go_workspace, pkg_def_file, out_file, package_vars) 796 info = build_pkg(cipd_exe, pkg_def, out_file, package_vars)
420 if upload: 797 if upload:
421 info = upload_pkg( 798 info = upload_pkg(
422 go_workspace, 799 cipd_exe,
423 out_file, 800 out_file,
424 service_url, 801 service_url,
425 tags, 802 tags,
426 service_account_json) 803 service_account_json)
427 assert info is not None 804 assert info is not None
428 succeeded.append({'pkg_def_name': name, 'info': info}) 805 succeeded.append({'pkg_def_name': pkg_def.name, 'info': info})
429 except (BuildException, UploadException) as e: 806 except (BuildException, UploadException) as e:
430 failed.append({'pkg_def_name': name, 'error': str(e)}) 807 failed.append({'pkg_def_name': pkg_def.name, 'error': str(e)})
431 808
432 print_title('Summary') 809 print_title('Summary')
433 for d in failed: 810 for d in failed:
434 print 'FAILED %s, see log above' % d['pkg_def_name'] 811 print 'FAILED %s, see log above' % d['pkg_def_name']
435 for d in succeeded: 812 for d in succeeded:
436 print '%s %s' % (d['info']['package'], d['info']['instance_id']) 813 print '%s %s' % (d['info']['package'], d['info']['instance_id'])
437 814
438 if json_output: 815 if json_output:
439 with open(json_output, 'w') as f: 816 with open(json_output, 'w') as f:
440 summary = { 817 summary = {
441 'failed': failed, 818 'failed': failed,
442 'succeeded': succeeded, 819 'succeeded': succeeded,
443 'tags': sorted(tags), 820 'tags': sorted(tags),
444 'vars': package_vars, 821 'vars': package_vars,
445 } 822 }
446 json.dump(summary, f, sort_keys=True, indent=2, separators=(',', ': ')) 823 json.dump(summary, f, sort_keys=True, indent=2, separators=(',', ': '))
447 824
448 return 1 if failed else 0 825 return 1 if failed else 0
449 826
450 827
451 def build_infra(): 828 def build_infra(pkg_defs):
452 """Builds infra.git multiverse.""" 829 """Builds infra.git multiverse.
453 # Python side. 830
454 print_title('Making sure python virtual environment is fresh') 831 Args:
455 run_python( 832 pkg_defs: list of PackageDef instances for packages being built.
456 script=os.path.join(ROOT, 'bootstrap', 'bootstrap.py'), 833 """
457 args=[ 834 # Skip building python if not used or if cross-compiling.
458 '--deps_file', 835 if any(p.uses_python_env for p in pkg_defs) and not is_cross_compiling():
459 os.path.join(ROOT, 'bootstrap', 'deps.pyl'), 836 print_title('Making sure python virtual environment is fresh')
460 os.path.join(ROOT, 'ENV'), 837 run_python(
461 ]) 838 script=os.path.join(ROOT, 'bootstrap', 'bootstrap.py'),
462 # Go side. 839 args=[
463 build_go(os.path.join(ROOT, 'go'), [ 840 '--deps_file',
464 'infra/...', 841 os.path.join(ROOT, 'bootstrap', 'deps.pyl'),
465 'github.com/luci/luci-go/client/...', 842 os.path.join(ROOT, 'ENV'),
466 'github.com/luci/luci-go/tools/...', 843 ])
467 ]) 844 # Build all necessary go binaries.
845 build_go_code(os.path.join(ROOT, 'go'), pkg_defs)
468 846
469 847
470 def main( 848 def main(
471 args, 849 args,
472 build_callback=build_infra, 850 build_callback=build_infra,
473 py_venv=os.path.join(ROOT, 'ENV'), 851 py_venv=os.path.join(ROOT, 'ENV'),
474 go_workspace=os.path.join(ROOT, 'go'), 852 go_workspace=os.path.join(ROOT, 'go'),
475 package_def_dir=os.path.join(ROOT, 'build', 'packages'), 853 package_def_dir=os.path.join(ROOT, 'build', 'packages'),
476 package_out_dir=os.path.join(ROOT, 'build', 'out')): 854 package_out_dir=os.path.join(ROOT, 'build', 'out')):
477 parser = argparse.ArgumentParser(description='Builds infra CIPD packages') 855 parser = argparse.ArgumentParser(description='Builds infra CIPD packages')
(...skipping 36 matching lines...) Expand 10 before | Expand all | Expand 10 after
514 args.build, 892 args.build,
515 args.upload, 893 args.upload,
516 args.service_url, 894 args.service_url,
517 args.tags or [], 895 args.tags or [],
518 args.service_account_json, 896 args.service_account_json,
519 args.json_output) 897 args.json_output)
520 898
521 899
522 if __name__ == '__main__': 900 if __name__ == '__main__':
523 sys.exit(main(sys.argv[1:])) 901 sys.exit(main(sys.argv[1:]))
OLDNEW
« build/README.md ('K') | « build/README.md ('k') | build/out/.gitignore » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698