Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 1 | #!/usr/bin/env vpython3 |
Greg Edelston | 81df4db | 2022-10-03 10:43:46 -0600 | [diff] [blame] | 2 | # Copyright 2022 The ChromiumOS Authors |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | |
| 6 | """Release a CrOS infra recipe bundle to prod.""" |
| 7 | |
| 8 | import argparse |
| 9 | import json |
| 10 | import os |
| 11 | import re |
| 12 | import string |
| 13 | import subprocess |
| 14 | import sys |
| 15 | import typing |
| 16 | from typing import List |
| 17 | from typing import Optional |
| 18 | from typing import Tuple |
| 19 | import urllib.parse |
| 20 | |
| 21 | RECIPES_DIR = os.path.dirname(os.path.realpath(__file__)) |
| 22 | RECIPE_BUNDLE = os.path.join('infra', 'recipe_bundles', |
| 23 | 'chromium.googlesource.com', 'chromiumos', 'infra', |
| 24 | 'recipes') |
| 25 | RE_TRIVIAL_COMMIT = re.compile(r'Roll recipe.*\(trivial\)\.?$') |
| 26 | STAGING_CHECKS = ( |
| 27 | "LegacyNoopSuccess", |
| 28 | "staging-amd64-generic-direct-tast-vm", |
| 29 | "staging-amd64-generic-postsubmit", |
| 30 | "staging-Annealing", |
| 31 | "staging-backfiller", |
| 32 | "staging-chrome-pupr-generator", |
George Engelbrecht | b50e766 | 2023-01-24 11:09:51 -0700 | [diff] [blame] | 33 | "staging-cq-orchestrator", |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 34 | "staging-DutTracker", |
| 35 | "staging-firmware-ti50-postsubmit", |
| 36 | "staging-manifest-doctor", |
| 37 | "staging-release-main-orchestrator", |
George Engelbrecht | a6a0e13 | 2022-10-08 13:22:58 -0600 | [diff] [blame] | 38 | "staging-release-triggerer", |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 39 | "staging-RoboCrop", |
George Engelbrecht | d878ac3 | 2022-10-05 11:14:24 -0600 | [diff] [blame] | 40 | "staging_SourceCacheBuilder", |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 41 | "staging-StarDoctor", |
| 42 | ) |
| 43 | |
| 44 | # CipdInstances are instance IDs, as found on the CIPD UI under "Instances". |
| 45 | # For example: "M4HmuQVGbx8YQVkM61c6LnCHVFgpJsSy1bI4DpBjSTwC" |
| 46 | CipdInstance = typing.NewType('CipdInstance', str) |
| 47 | # CipdRefs are named refs, as found on the CIPD UI under "Refs". |
| 48 | # For example: "prod" or "release_2022/09/23-12". |
| 49 | CipdRef = typing.NewType('CipdRef', str) |
| 50 | # CipdVersions can be either instance IDs or named refs. |
| 51 | # These are commonly accepted by the cipd CLI's `-version` flag. |
| 52 | CipdVersion = typing.Union[CipdInstance, CipdRef] |
| 53 | # GitHashes are the SHA of a Git commit (in the recipes repo). |
| 54 | # For example: "5d185ee5339976575a282971eef16f590405217f" |
| 55 | GitHash = typing.NewType('GitHash', str) |
| 56 | |
| 57 | |
| 58 | def main(argv: List[str]): |
| 59 | options = parse_args(argv) |
| 60 | setup() |
| 61 | |
| 62 | # Figure out which hashes/instances to use. |
| 63 | git_prod = get_git_prod_hash() |
| 64 | (cipd_target, git_target) = determine_cipd_and_git_targets(options.instanceid) |
| 65 | |
| 66 | # Prepare to update refs. |
| 67 | pending_changes = get_pending_changes(git_prod, git_target, |
| 68 | verbose=options.verbose) |
| 69 | report_pending_changes(pending_changes) |
| 70 | check_staging_builders(options.ignore_staging_failures) |
| 71 | quit_early_if_no_pending_changes(pending_changes) |
| 72 | if not options.force: |
| 73 | prompt_about_setting_git_target(git_target) |
| 74 | |
| 75 | # Update refs. |
| 76 | update_cipd_refs(cipd_target, dry_run=options.dry_run) |
| 77 | |
| 78 | # We did it! |
| 79 | print_email_link(pending_changes) |
| 80 | |
| 81 | |
| 82 | class Commit: |
| 83 | |
| 84 | def __init__(self, git_hash, username, message): |
| 85 | self.hash = git_hash |
| 86 | self.username = username |
| 87 | self.message = message |
| 88 | |
| 89 | def color_str(self) -> str: |
| 90 | """Return a colorified string for printing to stdout.""" |
| 91 | BOLDBLUE = '\033[1;34m' |
| 92 | BOLDGREEN = '\033[1;32m' |
| 93 | RESET = '\033[0m' |
| 94 | return f'{BOLDBLUE}{self.hash} {BOLDGREEN}[{self.username}] {RESET}{self.message}' |
| 95 | |
| 96 | def plain_str(self, with_bullet: bool = False) -> str: |
| 97 | """Return a colorless string for printing to email.""" |
| 98 | prefix = '* ' if with_bullet else '' |
| 99 | return f'{prefix}{self.hash} [{self.username}] {self.message}' |
| 100 | |
| 101 | |
| 102 | def parse_args(args: List[str]) -> argparse.Namespace: |
| 103 | """Interpret command-line args.""" |
| 104 | parser = argparse.ArgumentParser( |
| 105 | 'Release recipes by moving the "prod" ref forward.') |
| 106 | parser.add_argument('-d', '--dry-run', action='store_true', |
| 107 | help='Dry run: Don\'t actually change any cipd refs.') |
| 108 | parser.add_argument('-f', '--force', action='store_true', |
| 109 | help='Bypass the prompt.') |
| 110 | parser.add_argument( |
| 111 | '-i', '--instanceid', type=CipdInstance, |
| 112 | help='Release up to the commit specified by the instanceid. ' |
| 113 | 'Instanceids are found at:\n' |
| 114 | 'https://chrome-infra-packages.appspot.com/p/infra/recipe_bundles/chromium.googlesource.com/chromiumos/infra/recipes/+\n' |
| 115 | 'Click into an instance to see the commit attached to it.') |
| 116 | parser.add_argument('-s', '--ignore-staging-failures', action='store_true', |
| 117 | help='Release even if staging failures are present.') |
| 118 | parser.add_argument( |
| 119 | '-v', '--verbose', action='store_true', |
| 120 | help='Print all pending changes, including trivial recipe rolls.') |
| 121 | return parser.parse_args(args) |
| 122 | |
| 123 | |
| 124 | def setup(): |
| 125 | """Prepare for main logic.""" |
| 126 | git_remote_update() |
| 127 | print_cipd_versions_url() |
| 128 | |
| 129 | |
| 130 | def git_remote_update(): |
| 131 | """Run `git remote update` in the recipes dir.""" |
| 132 | subprocess.run(['git', 'remote', 'update'], stdout=subprocess.DEVNULL, |
| 133 | stderr=subprocess.DEVNULL, cwd=RECIPES_DIR, check=True) |
| 134 | |
| 135 | |
| 136 | def print_cipd_versions_url(): |
| 137 | """Tell the user where to get info about CIPD versions.""" |
| 138 | print('CIPD versions (instances and refs) can be found here:') |
| 139 | print( |
| 140 | 'https://chrome-infra-packages.appspot.com/p/infra/recipe_bundles/chromium.googlesource.com/chromiumos/infra/recipes/+/' |
| 141 | ) |
| 142 | print() |
| 143 | |
| 144 | |
| 145 | def get_git_prod_hash() -> GitHash: |
| 146 | """Get the git hash for the current prod ref.""" |
| 147 | return cipd_version_to_githash(CipdRef('prod')) |
| 148 | |
| 149 | |
| 150 | def cipd_version_to_githash(version: CipdVersion) -> GitHash: |
| 151 | """Find the git hash for a recipes CIPD instance (whether named ref or ID). |
| 152 | |
| 153 | Sample `cipd describe` output: |
| 154 | Package: infra/recipe_bundles/chromium.googlesource.com/chromiumos/infra/recipes |
| 155 | Instance ID: dv0onkHQ71tjY2cvQiePm-ve1tCfbHfvi40xJWs5EbAC |
| 156 | Registered by: user:infra-internal-recipe-bundler@chops-service-accounts.iam.gserviceaccount.com |
| 157 | Registered at: 2022-09-23 13:11:22.569365 -0600 MDT |
| 158 | Refs: |
| 159 | prod |
| 160 | release_2022/09/23-12 |
| 161 | Tags: |
| 162 | git_revision:1114d30c71229c5cd470df15f863e9ddcfff6fb5 |
| 163 | """ |
| 164 | cmd = ['cipd', 'describe', '-version', version, RECIPE_BUNDLE] |
| 165 | p = subprocess.run(cmd, text=True, capture_output=True, check=True) |
| 166 | lines = p.stdout.split('\n') |
| 167 | git_rev_lines = [line for line in lines if 'git_revision' in line] |
Greg Edelston | 7f723fb | 2022-10-03 11:08:56 -0600 | [diff] [blame] | 168 | assert len(git_rev_lines) >= 1, lines |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 169 | git_rev_line = git_rev_lines[0] |
| 170 | assert git_rev_line.count(':') == 1, git_rev_line |
| 171 | githash = git_rev_line.strip().split(':')[1] |
| 172 | assert all(c in string.hexdigits for c in githash), githash |
| 173 | return GitHash(githash) |
| 174 | |
| 175 | |
| 176 | def determine_cipd_and_git_targets(instanceid: Optional[CipdInstance] = None |
| 177 | ) -> Tuple[CipdInstance, GitHash]: |
| 178 | """Find the target CIPD instance (if not provided) and Git hash.""" |
| 179 | if instanceid: |
| 180 | git_target = cipd_version_to_githash(instanceid) |
| 181 | cipd_target = instanceid |
| 182 | else: |
| 183 | git_target = cipd_version_to_githash(CipdInstance('refs/heads/main')) |
| 184 | cipd_target = cipd_ref_to_instance_id(CipdRef(f'git_revision:{git_target}')) |
| 185 | return (cipd_target, git_target) |
| 186 | |
| 187 | |
| 188 | def cipd_ref_to_instance_id(ref: CipdRef) -> CipdInstance: |
| 189 | """Find the instanceid associated with a recipes CIPD ref.""" |
| 190 | p = subprocess.run(['cipd', 'resolve', '-version', ref, RECIPE_BUNDLE], |
| 191 | capture_output=True, text=True, check=True) |
| 192 | stdout = [line.strip() for line in p.stdout.split('\n') if line] |
Greg Edelston | 81df4db | 2022-10-03 10:43:46 -0600 | [diff] [blame] | 193 | instance_id = stdout[-1].split(':')[-1] |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 194 | assert len(instance_id.split()) == 1, instance_id |
| 195 | return CipdInstance(instance_id) |
| 196 | |
| 197 | |
| 198 | def get_pending_changes(from_hash: GitHash, to_hash: GitHash, |
| 199 | verbose: bool = False) -> List[Commit]: |
| 200 | """Find all changes that will be released. |
| 201 | |
| 202 | from_hash: The git hash immediately preceding the first in the changelist. |
| 203 | to_hash: The final git hash of the changelist. |
| 204 | verbose: If False, exclude trivial recipe rolls. |
| 205 | """ |
| 206 | print('=== Checking for pending changes ===') |
| 207 | print('Here are the changes from the provided (or default main) environment:') |
| 208 | if verbose: |
| 209 | print(' - Verbose specified, printing all changes') |
| 210 | fmt = '%h %al %s' # %h=commit, %al=user, %s=summary |
| 211 | cmd = [ |
| 212 | 'git', 'log', '--graph', f'--pretty=format:{fmt}', |
| 213 | f'{from_hash}..{to_hash}' |
| 214 | ] |
| 215 | p = subprocess.run(cmd, capture_output=True, text=True, cwd=RECIPES_DIR, |
| 216 | check=True) |
| 217 | if p.stderr: |
| 218 | print(f'Error running cmd: {cmd}') |
| 219 | print(p.stderr) |
| 220 | sys.exit(1) |
| 221 | lines = [line.strip().lstrip('* ') for line in p.stdout.split('\n')] |
| 222 | changes = [] |
| 223 | for line in lines: |
| 224 | if not line: |
| 225 | continue |
| 226 | commit_hash, commit_user, commit_message = line.split(' ', 2) |
| 227 | if RE_TRIVIAL_COMMIT.match(commit_message) and not verbose: |
| 228 | continue |
| 229 | changes.append(Commit(commit_hash, commit_user, commit_message)) |
| 230 | return changes |
| 231 | |
| 232 | |
| 233 | def report_pending_changes(pending_changes: List[Commit]): |
| 234 | """Pretty-print info about all the pending changes.""" |
| 235 | for pending_change in pending_changes: |
| 236 | print(f'* {pending_change.color_str()}') |
| 237 | print() |
| 238 | |
| 239 | |
| 240 | def check_staging_builders(ignore_failures: bool = False): |
| 241 | """Check for failures in staging builders. Quit early if any problems.""" |
| 242 | print('=== Check staging status ===') |
| 243 | baddies = [] |
George Engelbrecht | b50e766 | 2023-01-24 11:09:51 -0700 | [diff] [blame] | 244 | print( |
| 245 | 'Looking for 5 consecutive successes in staging, showing only failures...' |
| 246 | ) |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 247 | for name in STAGING_CHECKS: |
| 248 | builder = f'chromeos/staging/{name}' |
| 249 | if has_builder_had_non_success(builder): |
| 250 | baddies.append(name) |
| 251 | if baddies: |
| 252 | if ignore_failures: |
| 253 | print('Ignoring failures, as requested.') |
| 254 | else: |
| 255 | print('Please address the failures in the above builders.') |
| 256 | print('When you\'re certain staging is OK, you may use -s to continue.') |
| 257 | sys.exit(1) |
| 258 | print() |
| 259 | |
| 260 | |
| 261 | def has_builder_had_non_success(builder: str) -> bool: |
| 262 | """Check whether a single builder has had any recent non-successes.""" |
George Engelbrecht | b50e766 | 2023-01-24 11:09:51 -0700 | [diff] [blame] | 263 | cmd = ['bb', 'ls', '-status', 'ended', '-n', '5', '-json', builder] |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 264 | p = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| 265 | unique_statuses = [] |
| 266 | for line in p.stdout.split('\n'): |
| 267 | if not line: |
| 268 | continue |
| 269 | status = json.loads(line)['status'] |
| 270 | if status in ('STARTED', 'SCHEDULED'): |
| 271 | continue |
| 272 | if status not in unique_statuses: |
| 273 | unique_statuses.append(status) |
George Engelbrecht | b50e766 | 2023-01-24 11:09:51 -0700 | [diff] [blame] | 274 | if unique_statuses != ['SUCCESS']: |
| 275 | print(f'Non-success: {builder} --> {", ".join(unique_statuses)}') |
| 276 | return True |
| 277 | return False |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 278 | |
| 279 | |
| 280 | def quit_early_if_no_pending_changes(pending_changes: List[Commit]): |
| 281 | """If there are no pending changes, exit gracefully.""" |
| 282 | if not pending_changes: |
| 283 | print('No changes pending. Exiting early.') |
| 284 | sys.exit(0) |
| 285 | |
| 286 | |
| 287 | def prompt_about_setting_git_target(git_target: GitHash): |
| 288 | """Ask the user whether it's OK to change the git target. If not, exit.""" |
| 289 | if input(f'Set prod to git @ {git_target}? (y/N): ').upper() != 'Y': |
| 290 | sys.exit(0) |
| 291 | |
| 292 | |
| 293 | def update_cipd_refs(cipd_target: CipdInstance, dry_run: bool = False): |
| 294 | """Set the prod ref and timestamped ref to the given cipd instance.""" |
| 295 | prod_ref = CipdRef('prod') |
| 296 | timestamped_ref = CipdRef(f'release_{get_timestamp("%Y/%m/%d-%H")}') |
| 297 | for ref in (prod_ref, timestamped_ref): |
| 298 | cmd = [ |
| 299 | 'cipd', 'set-ref', RECIPE_BUNDLE, f'-version={cipd_target}', |
| 300 | f'-ref={ref}' |
| 301 | ] |
| 302 | if dry_run: |
| 303 | print('Not actually running the following command:') |
| 304 | print('\t ', ' '.join(cmd)) |
| 305 | else: |
| 306 | subprocess.run(cmd, check=True) |
| 307 | |
| 308 | |
| 309 | def get_timestamp(fmt: str = ''): |
| 310 | """Get a current timestamp in California time. |
| 311 | |
| 312 | Use Bash's `date` because Python standard lib is shockingly bad at timezones |
| 313 | prior to Py3.9. |
| 314 | """ |
| 315 | env = {'TZ': 'America/Los_Angeles'} |
| 316 | cmd = ['date'] |
| 317 | if fmt: |
| 318 | cmd.append(f'+{fmt}') |
| 319 | p = subprocess.run(cmd, capture_output=True, text=True, env=env, check=True) |
Greg Edelston | 0832146 | 2022-10-04 16:01:58 -0600 | [diff] [blame] | 320 | return p.stdout.strip() |
Greg Edelston | 5df44f1 | 2022-09-27 11:30:14 -0600 | [diff] [blame] | 321 | |
| 322 | |
| 323 | def print_email_link(pending_changes: List[Commit]): |
| 324 | """Show the user an email link to announce the new change.""" |
| 325 | print() |
| 326 | print('Please click this link and send an email to chromeos-infra-releases!') |
| 327 | print() |
| 328 | print(get_email_link(pending_changes)) |
| 329 | |
| 330 | |
| 331 | def get_email_link(pending_changes: List[Commit]) -> str: |
| 332 | """Create an email link to announce the new change.""" |
| 333 | email_subject = f'Recipes Release - {get_timestamp()}' |
| 334 | email_message = '\n'.join([ |
| 335 | 'We\'ve deployed Recipes to prod!', |
| 336 | '', |
| 337 | 'Here is a summary of the changes:', |
| 338 | '', |
| 339 | '\n'.join( |
| 340 | change.plain_str(with_bullet=True) for change in pending_changes), |
| 341 | ]) |
| 342 | url_params = urllib.parse.urlencode({ |
| 343 | 'view': 'cm', |
| 344 | 'fs': 1, |
| 345 | 'bcc': 'chromeos-infra-releases@google.com', |
| 346 | 'to': 'chromeos-continuous-integration-team@google.com', |
| 347 | 'su': email_subject, |
| 348 | 'body': email_message, |
| 349 | }) |
| 350 | url = f'https://mail.google.com/mail?{url_params}' |
| 351 | return url |
| 352 | |
| 353 | |
| 354 | if __name__ == '__main__': |
| 355 | main(sys.argv[1:]) |