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