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