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