1#!/usr/bin/env python3 2# Copyright 2022 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6# This script is used by the CI system to regularly update the merge and dry run changes. 7# 8# It can be run locally as well, however some permissions are only given to the bot's service 9# account (and are enabled with --is-bot). 10# 11# See `./tools/chromeos/merge_bot -h` for details. 12# 13# When testing this script locally, use MERGE_BOT_TEST=1 ./tools/chromeos/merge_bot 14# to use different tags and prevent emails from being sent or the CQ from being triggered. 15 16from contextlib import contextmanager 17import os 18from pathlib import Path 19import sys 20from datetime import date 21from typing import List 22import random 23import string 24 25sys.path.append(os.path.dirname(sys.path[0])) 26 27import re 28 29from impl.common import CROSVM_ROOT, batched, cmd, quoted, run_commands, GerritChange, GERRIT_URL 30 31git = cmd("git") 32git_log = git("log --decorate=no --color=never") 33curl = cmd("curl --silent --fail") 34chmod = cmd("chmod") 35dev_container = cmd("tools/dev_container") 36mkdir = cmd("mkdir -p") 37 38UPSTREAM_URL = "https://chromium.googlesource.com/crosvm/crosvm" 39CROS_URL = "https://chromium.googlesource.com/chromiumos/platform/crosvm" 40 41# Gerrit tags used to identify bot changes. 42TESTING = "MERGE_BOT_TEST" in os.environ 43if TESTING: 44 MERGE_TAG = "testing-crosvm-merge" 45 DRY_RUN_TAG = "testing-crosvm-merge-dry-run" 46else: 47 MERGE_TAG = "crosvm-merge" # type: ignore 48 DRY_RUN_TAG = "crosvm-merge-dry-run" # type: ignore 49 50# This is the email of the account that posts CQ messages. 51LUCI_EMAIL = "[email protected]" 52 53# Do not create more dry runs than this within a 24h timespan 54MAX_DRY_RUNS_PER_DAY = 2 55 56 57def list_active_merges(): 58 return GerritChange.query( 59 "project:chromiumos/platform/crosvm", 60 "branch:chromeos", 61 "status:open", 62 f"hashtag:{MERGE_TAG}", 63 ) 64 65 66def list_active_dry_runs(): 67 return GerritChange.query( 68 "project:chromiumos/platform/crosvm", 69 "branch:chromeos", 70 "status:open", 71 f"hashtag:{DRY_RUN_TAG}", 72 ) 73 74 75def list_recent_dry_runs(age: str): 76 return GerritChange.query( 77 "project:chromiumos/platform/crosvm", 78 "branch:chromeos", 79 f"-age:{age}", 80 f"hashtag:{DRY_RUN_TAG}", 81 ) 82 83 84def bug_notes(commit_range: str): 85 "Returns a string with all BUG=... lines of the specified commit range." 86 return "\n".join( 87 set( 88 line 89 for line in git_log(commit_range, "--pretty=%b").lines() 90 if re.match(r"^BUG=", line, re.I) and not re.match(r"^BUG=None", line, re.I) 91 ) 92 ) 93 94 95def setup_tracking_branch(branch_name: str, tracking: str): 96 "Create and checkout `branch_name` tracking `tracking`. Overwrites existing branch." 97 git("fetch -q cros", tracking).fg() 98 git("checkout", f"cros/{tracking}").fg(quiet=True) 99 git("branch -D", branch_name).fg(quiet=True, check=False) 100 git("checkout -b", branch_name, "--track", f"cros/{tracking}").fg() 101 102 103@contextmanager 104def tracking_branch_context(branch_name: str, tracking: str): 105 "Switches to a tracking branch and back after the context is exited." 106 # Remember old head. Prefer branch name if available, otherwise revision of detached head. 107 old_head = git("symbolic-ref -q --short HEAD").stdout(check=False) 108 if not old_head: 109 old_head = git("rev-parse HEAD").stdout() 110 setup_tracking_branch(branch_name, tracking) 111 yield 112 git("checkout", old_head).fg() 113 114 115def gerrit_prerequisites(): 116 "Make sure we can upload to gerrit." 117 118 # Setup cros remote which we are merging into 119 if git("remote get-url cros").fg(check=False) != 0: 120 print("Setting up remote: cros") 121 git("remote add cros", CROS_URL).fg() 122 actual_remote = git("remote get-url cros").stdout() 123 if actual_remote != CROS_URL: 124 print(f"WARNING: Your remote 'cros' is {actual_remote} and does not match {CROS_URL}") 125 126 # Install gerrit Change-Id hook 127 hook_path = CROSVM_ROOT / ".git/hooks/commit-msg" 128 if not hook_path.exists(): 129 hook_path.parent.mkdir(exist_ok=True) 130 curl(f"{GERRIT_URL}/tools/hooks/commit-msg").write_to(hook_path) 131 chmod("+x", hook_path).fg() 132 133 134def upload_to_gerrit(target_branch: str, *extra_params: str): 135 if not TESTING: 136 extra_params = ("[email protected]", *extra_params) 137 for i in range(3): 138 try: 139 print(f"Uploading to gerrit (Attempt {i})") 140 git(f"push cros HEAD:refs/for/{target_branch}%{','.join(extra_params)}").fg() 141 return 142 except: 143 continue 144 raise Exception("Could not upload changes to gerrit.") 145 146 147def rename_files_to_random(dir_path: str): 148 "Rename all files in a folder to random file names with extension kept" 149 print("Renaming all files in " + dir_path) 150 file_names = os.listdir(dir_path) 151 for file_name in filter(os.path.isfile, map(lambda x: os.path.join(dir_path, x), file_names)): 152 file_extension = os.path.splitext(file_name)[1] 153 new_name_stem = "".join( 154 random.choice(string.ascii_lowercase + string.digits) for _ in range(16) 155 ) 156 new_path = os.path.join(dir_path, new_name_stem + file_extension) 157 print(f"Renaming {file_name} to {new_path}") 158 os.rename(file_name, new_path) 159 160 161def create_pgo_profile(): 162 "Create PGO profile matching HEAD at merge." 163 has_kvm = os.path.exists("/dev/kvm") 164 if not has_kvm: 165 return 166 os.chdir(CROSVM_ROOT) 167 tmpdirname = "target/pgotmp/" + "".join( 168 random.choice(string.ascii_lowercase + string.digits) for _ in range(16) 169 ) 170 mkdir(tmpdirname).fg() 171 benchmark_list = list( 172 map( 173 lambda x: os.path.splitext(x)[0], 174 filter(lambda x: x.endswith(".rs"), os.listdir("e2e_tests/benches")), 175 ) 176 ) 177 print(f"Building instrumented binary, perf data will be saved to {tmpdirname}") 178 dev_container( 179 "./tools/build_release --build-profile release --profile-generate /workspace/" + tmpdirname 180 ).fg() 181 print() 182 print("List of benchmarks to run:") 183 for bench_name in benchmark_list: 184 print(bench_name) 185 print() 186 dev_container("mkdir -p /var/empty").fg() 187 for bench_name in benchmark_list: 188 print(f"Running bechmark: {bench_name}") 189 dev_container(f"./tools/bench {bench_name}").fg() 190 # Instrumented binary always give same file name to generated .profraw files, rename to avoid 191 # overwriting profile from previous bench suite 192 rename_files_to_random(tmpdirname) 193 mkdir("profiles").fg() 194 dev_container( 195 f"cargo profdata -- merge -o /workspace/profiles/benchmarks.profdata /workspace/{tmpdirname}" 196 ).fg() 197 dev_container("xz -f -9e -T 0 /workspace/profiles/benchmarks.profdata").fg() 198 199 200#################################################################################################### 201# The functions below are callable via the command line 202 203 204def create_merge_commits( 205 revision: str, max_size: int = 0, create_dry_run: bool = False, force_pgo: bool = False 206): 207 "Merges `revision` into HEAD, creating merge commits including at most `max-size` commits." 208 os.chdir(CROSVM_ROOT) 209 210 # Find list of commits to merge, then batch them into smaller merges. 211 commits = git_log(f"HEAD..{revision}", "--pretty=%H").lines() 212 if not commits: 213 print("Nothing to merge.") 214 return (0, False) 215 else: 216 commit_authors = git_log(f"HEAD..{revision}", "--pretty=%an").lines() 217 if all(map(lambda x: x == "recipe-roller", commit_authors)): 218 print("All commits are from recipe roller, don't merge yet") 219 return (0, False) 220 221 # Create a merge commit for each batch 222 batches = list(batched(commits, max_size)) if max_size > 0 else [commits] 223 has_conflicts = False 224 for i, batch in enumerate(reversed(batches)): 225 target = batch[0] 226 previous_rev = git(f"rev-parse {batch[-1]}^").stdout() 227 commit_range = f"{previous_rev}..{batch[0]}" 228 229 # Put together a message containing info about what's in the merge. 230 batch_str = f"{i + 1}/{len(batches)}" if len(batches) > 1 else "" 231 title = "Merge with upstream" if not create_dry_run else f"Merge dry run" 232 message = "\n\n".join( 233 [ 234 f"{title} {date.today().isoformat()} {batch_str}", 235 git_log(commit_range, "--oneline").stdout(), 236 f"{UPSTREAM_URL}/+log/{commit_range}", 237 *([bug_notes(commit_range)] if not create_dry_run else []), 238 ] 239 ) 240 241 # git 'trailers' go into a separate paragraph to make sure they are properly separated. 242 trailers = "Commit: False" if create_dry_run or TESTING else "" 243 244 # Perfom merge 245 code = git("merge --no-ff", target, "-m", quoted(message), "-m", quoted(trailers)).fg( 246 check=False 247 ) 248 if code != 0: 249 if not Path(".git/MERGE_HEAD").exists(): 250 raise Exception("git merge failed for a reason other than merge conflicts.") 251 print("Merge has conflicts. Creating commit with conflict markers.") 252 git("add --update .").fg() 253 message = f"(CONFLICT) {message}" 254 git("commit", "-m", quoted(message), "-m", quoted(trailers)).fg() 255 has_conflicts = True 256 # Only uprev PGO profile on Monday to reduce impact on repo size 257 # TODO: b/181105093 - Re-evaluate throttling strategy after sometime 258 if date.today().weekday() == 0 or force_pgo: 259 create_pgo_profile() 260 git("add profiles/benchmarks.profdata.xz").fg() 261 git("commit --amend --no-edit").fg() 262 263 return (len(batches), has_conflicts) 264 265 266def status(): 267 "Shows the current status of pending merge and dry run changes in gerrit." 268 print("Active dry runs:") 269 for dry_run in list_active_dry_runs(): 270 print(dry_run.pretty_info()) 271 print() 272 print("Active merges:") 273 for merge in list_active_merges(): 274 print(merge.pretty_info()) 275 276 277def update_merges( 278 revision: str, 279 target_branch: str = "chromeos", 280 max_size: int = 15, 281 is_bot: bool = False, 282): 283 """Uploads a new set of merge commits if the previous batch has been submitted.""" 284 gerrit_prerequisites() 285 parsed_revision = git("rev-parse", revision).stdout() 286 287 active_merges = list_active_merges() 288 if active_merges: 289 print("Nothing to do. Previous merges are still pending:") 290 for merge in active_merges: 291 print(merge.pretty_info()) 292 return 293 else: 294 print(f"Creating merge of {parsed_revision} into cros/{target_branch}") 295 with tracking_branch_context("merge-bot-branch", target_branch): 296 count, has_conflicts = create_merge_commits( 297 parsed_revision, max_size, create_dry_run=False 298 ) 299 if count > 0: 300 labels: List[str] = [] 301 if not has_conflicts: 302 if not TESTING: 303 labels.append("l=Commit-Queue+1") 304 if is_bot: 305 labels.append("l=Bot-Commit+1") 306 upload_to_gerrit(target_branch, f"hashtag={MERGE_TAG}", *labels) 307 308 309def update_dry_runs( 310 revision: str, 311 target_branch: str = "chromeos", 312 max_size: int = 0, 313 is_bot: bool = False, 314): 315 """ 316 Maintains dry run changes in gerrit, usually run by the crosvm bot, but can be called by 317 developers as well. 318 """ 319 gerrit_prerequisites() 320 parsed_revision = git("rev-parse", revision).stdout() 321 322 # Close active dry runs if they are done. 323 print("Checking active dry runs") 324 for dry_run in list_active_dry_runs(): 325 cq_votes = dry_run.get_votes("Commit-Queue") 326 if not cq_votes or max(cq_votes) > 0: 327 print(dry_run, "CQ is still running.") 328 continue 329 330 # Check for luci results and add V+-1 votes to make it easier to identify failed dry runs. 331 luci_messages = dry_run.get_messages_by(LUCI_EMAIL) 332 if not luci_messages: 333 print(dry_run, "No luci messages yet.") 334 continue 335 336 last_luci_message = luci_messages[-1] 337 if "This CL passed the CQ dry run" in last_luci_message or ( 338 "This CL has passed the run" in last_luci_message 339 ): 340 dry_run.review( 341 "I think this dry run was SUCCESSFUL.", 342 { 343 "Verified": 1, 344 "Bot-Commit": 0, 345 }, 346 ) 347 elif "Failed builds" in last_luci_message or ( 348 "This CL has failed the run. Reason:" in last_luci_message 349 ): 350 dry_run.review( 351 "I think this dry run FAILED.", 352 { 353 "Verified": -1, 354 "Bot-Commit": 0, 355 }, 356 ) 357 358 dry_run.abandon("Dry completed.") 359 360 active_dry_runs = list_active_dry_runs() 361 if active_dry_runs: 362 print("There are active dry runs, not creating a new one.") 363 print("Active dry runs:") 364 for dry_run in active_dry_runs: 365 print(dry_run.pretty_info()) 366 return 367 368 num_dry_runs = len(list_recent_dry_runs("1d")) 369 if num_dry_runs >= MAX_DRY_RUNS_PER_DAY: 370 print(f"Already created {num_dry_runs} in the past 24h. Not creating another one.") 371 return 372 373 print(f"Creating dry run merge of {parsed_revision} into cros/{target_branch}") 374 with tracking_branch_context("merge-bot-branch", target_branch): 375 count, has_conflicts = create_merge_commits( 376 parsed_revision, max_size, create_dry_run=True, force_pgo=True 377 ) 378 if count > 0 and not has_conflicts: 379 upload_to_gerrit( 380 target_branch, 381 f"hashtag={DRY_RUN_TAG}", 382 *(["l=Commit-Queue+1"] if not TESTING else []), 383 *(["l=Bot-Commit+1"] if is_bot else []), 384 ) 385 else: 386 if has_conflicts: 387 print("Not uploading dry-run with conflicts.") 388 else: 389 print("Nothing to upload.") 390 391 392run_commands( 393 create_merge_commits, 394 status, 395 update_merges, 396 update_dry_runs, 397 gerrit_prerequisites, 398) 399