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