blob: fdf795a9bb26fe5190944c76e795d14b0319ce04 [file] [log] [blame]
Sean McAllisterffce55f2021-02-22 20:08:18 -07001#!/usr/bin/env python3
2# Copyright 2021 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Run an equivalent to the backfill pipeline locally and generate diffs.
6
7Parse the actual current builder configurations from BuildBucket and run
8the join_config_payloads.py script locally. Generate a diff that shows any
9changes using the tip-of-tree code vs what's running in production.
10"""
11
12import argparse
13import collections
Sean McAllister9b5a33e2021-02-26 10:53:54 -070014import functools
Sean McAllisterffce55f2021-02-22 20:08:18 -070015import itertools
16import json
17import multiprocessing
18import multiprocessing.pool
19import os
20import pathlib
21import subprocess
22import sys
23import tempfile
24import time
25
26# resolve relative directories
27this_dir = pathlib.Path(os.path.dirname(os.path.abspath(__file__)))
28hwid_path = (this_dir / "../../platform/chromeos-hwid/v3").resolve()
29join_script = (this_dir / "../payload_utils/join_config_payloads.py").resolve()
30public_path = (this_dir / "../../overlays").resolve()
31private_path = (this_dir / "../../private-overlays").resolve()
32project_path = (this_dir / "../../project").resolve()
33
34# escape sequence to clear the current line and return to column 0
35CLEAR_LINE = "\033[2K\r"
36
37# record to store backfiller configuration in
38BackfillConfig = collections.namedtuple('BackfillConfig', [
39 'program',
40 'project',
41 'hwid_key',
42 'public_model',
43 'private_repo',
44 'private_model',
45])
46
47
48class Spinner(object):
49 """Simple class to print a message and update a little spinning icon."""
50
51 def __init__(self, message):
52 self.message = message
53 self.spin = itertools.cycle("◐◓◑◒")
54
55 def tick(self):
56 sys.stderr.write(CLEAR_LINE + "[%c] %s" % (next(self.spin), self.message))
57
58 def done(self, success=True):
59 if success:
60 sys.stderr.write(CLEAR_LINE + "[✔] %s\n" % self.message)
61 else:
62 sys.stderr.write(CLEAR_LINE + "[✘] %s\n" % message)
63
64
65def call_and_spin(message, stdin, *cmd):
66 """Execute a command and print a nice status while we wait.
67
68 Args:
69 message (str): message to print while we wait (along with spinner)
70 stdin (bytes): array of bytes to send as the stdin (or None)
71 cmd ([str]): command and any options and arguments
72
73 Return:
74 tuple of (data, status) containing process stdout and status
75 """
76
77 with multiprocessing.pool.ThreadPool(processes=1) as pool:
78 result = pool.apply_async(subprocess.run, (cmd,), {
79 'input': stdin,
80 'capture_output': True,
81 'text': True,
82 })
83
84 spinner = Spinner(message)
85 spinner.tick()
86
87 while not result.ready():
88 spinner.tick()
89 time.sleep(0.05)
90
91 process = result.get()
92 spinner.done(process.returncode == 0)
93
94 return process.stdout, process.returncode
95
96
97def parse_build_property(build, name):
98 """Parse out a property value from a build and return its value.
99
100 Properties are always JSON values, so we decode them and return the
101 resulting object
102
103 Args:
104 build (dict): json object containing BuildBucket properties
105 name (str): name of the property to look up
106
107 Return:
108 decoded property value or None if not found
109 """
110
111 properties = build["config"]["recipe"]["propertiesJ"]
112 for prop in properties:
113 if prop.startswith(name):
114 return json.loads(prop[len(name) + 1:])
115 return None
116
117
Sean McAllister9b5a33e2021-02-26 10:53:54 -0700118def run_backfill(config, logname=None):
119 """Run a single backfill job, return diff of current and new output.
120
121 Args:
122 config: BackfillConfig instance for the backfill operation.
123 logname: Filename to redirect stderr to from backfill
124 default is to supress the output
125 """
126
127 logfile = subprocess.DEVNULL
128 if logname:
129 logfile = open(logname, "a")
Sean McAllisterffce55f2021-02-22 20:08:18 -0700130
131 # reef is currently broken because it _needs_ a real portage environment
132 # to pull in common code.
133 # TODO(https://crbug.com/1144956): fix when reef is corrected
Sean McAllisterffce55f2021-02-22 20:08:18 -0700134 if config.program == "reef":
135 return None
136
Sean McAllister9b5a33e2021-02-26 10:53:54 -0700137 cmd = [join_script, "--l", "DEBUG"]
Sean McAllisterffce55f2021-02-22 20:08:18 -0700138 cmd.extend(["--program-name", config.program])
139 cmd.extend(["--project-name", config.project])
140
141 if config.hwid_key:
142 cmd.extend(["--hwid", hwid_path / config.hwid_key])
143
144 if config.public_model:
145 cmd.extend(["--public-model", public_path / config.public_model])
146
147 if config.private_model:
148 overlay = config.private_repo.split('/')[-1]
149 cmd.extend(
150 ["--private-model", private_path / overlay / config.private_model])
151
152 # create temporary directory for output
153 with tempfile.TemporaryDirectory() as tmpdir:
154 imported = project_path / config.program / config.project / "generated/imported.jsonproto"
155 output = pathlib.Path(tmpdir) / "output.jsonproto"
156 cmd.extend(["--output", output])
157
158 # execute the backfill
Sean McAllister9b5a33e2021-02-26 10:53:54 -0700159 result = subprocess.run(cmd, stderr=logfile)
Sean McAllisterffce55f2021-02-22 20:08:18 -0700160 if result.returncode != 0:
161 print("Error executing backfill for {}-{}".format(config.program,
162 config.project))
163 return None
164
165 # use jq to generate a nice diff of the output if it exists
166 if imported.exists():
167 process = subprocess.run(
168 "diff -u <(jq -S . {}) <(jq -S . {})".format(imported, output),
169 shell=True,
170 text=True,
171 capture_output=True)
172
173 return ("{}-{}".format(config.program, config.project), process.stdout)
174 return None
175
176
177def run_backfills(args, configs):
178 """Run backfill pipeline for each builder in configs.
179
180 Generate an über diff showing the changes that the current ToT
181 join_config_payloads code would generate vs what's currently committed.
182
183 Write the result to the output file specified on the command line.
184
185 Args:
186 args: command line arguments from argparse
187 configs: list of BackfillConfig instances to execute
188
189 Return:
190 nothing
191 """
192
Sean McAllister9b5a33e2021-02-26 10:53:54 -0700193 # create a logfile if requested
194 kwargs = {}
195 if args.logfile:
196 # open and close the logfile to truncate it so backfills can append
197 # We can't pickle the file object and send it as an argument with
198 # multiprocessing, so this is a workaround for that limitation
199 with open(args.logfile, "w"):
200 kwargs["logname"] = args.logfile
201
Sean McAllisterffce55f2021-02-22 20:08:18 -0700202 nproc = 32
203 nconfig = len(configs)
204 output = {}
205 with multiprocessing.Pool(processes=nproc) as pool:
Sean McAllister9b5a33e2021-02-26 10:53:54 -0700206 results = pool.imap_unordered(
207 functools.partial(run_backfill, **kwargs), configs, chunksize=1)
Sean McAllisterffce55f2021-02-22 20:08:18 -0700208 for ii, result in enumerate(results, 1):
209 sys.stderr.write(
210 CLEAR_LINE + "[{}/{}] Processing backfills".format(ii, nconfig),)
211
212 if result:
213 id, data = result
214 output[id] = data
215
216 sys.stderr.write(CLEAR_LINE + "[✔] Processing backfills")
217
218 # generate final über diff showing all the changes
219 with open(args.output, "w") as ofile:
220 all_empty = True
221 for name, result in sorted(output.items()):
222 ofile.write("## ---------------------\n")
223 ofile.write("## diff for {}\n".format(name))
224 ofile.write("\n")
225 ofile.write(result + "\n")
226
227 all_empty = all_empty and result.strip() == ""
228
229 if all_empty:
230 print("No diffs detected!\n")
231
232
233def main():
234 parser = argparse.ArgumentParser(
235 description=__doc__,
236 formatter_class=argparse.RawTextHelpFormatter,
237 )
238
239 parser.add_argument(
240 "-o",
241 "--output",
242 type=str,
243 required=True,
244 help="target file for diff information",
245 )
Sean McAllister9b5a33e2021-02-26 10:53:54 -0700246 parser.add_argument(
247 "-l",
248 "--logfile",
249 type=str,
250 help="target file to log output from backfills",
251 )
Sean McAllisterffce55f2021-02-22 20:08:18 -0700252 args = parser.parse_args()
253
254 # query BuildBucket for current builder configurations in the infra bucket
255 data, status = call_and_spin(
256 "Listing backfill builders",
257 json.dumps({
258 "project": "chromeos",
259 "bucket": "infra",
260 "pageSize": 1000,
261 }),
262 "prpc",
263 "call",
264 "cr-buildbucket.appspot.com",
265 "buildbucket.v2.Builders.ListBuilders",
266 )
267
268 if status != 0:
269 print(
270 "Error executing prpc call to list builders. Try 'prpc login' first.",
271 file=sys.stderr,
272 )
273 sys.exit(status)
274
275 # filter out just the backfill builders and sort them by name
276 builders = json.loads(data)["builders"]
277 builders = [
278 bb for bb in builders if bb["id"]["builder"].startswith("backfill")
279 ]
280
281 # construct backfill config from the configured builder properties
282 configs = []
283 for builder in builders:
284 public_yaml = parse_build_property(builder, "public_yaml") or {}
285 private_yaml = parse_build_property(builder, "private_yaml") or {}
286
287 configs.append(
288 BackfillConfig(
289 program=parse_build_property(builder, "program_name"),
290 project=parse_build_property(builder, "project_name"),
291 hwid_key=parse_build_property(builder, "hwid_key"),
292 public_model=public_yaml.get("path"),
293 private_repo=private_yaml.get("repo"),
294 private_model=private_yaml.get("path"),
295 ))
296
297 run_backfills(args, configs)
298
299
300if __name__ == "__main__":
301 main()