blob: a6e569d4256f71d63d3e9a502cb7e7bdc9f85d67 [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",
George Engelbrechta6a0e132022-10-08 13:22:58 -060037 "staging-release-triggerer",
Greg Edelston5df44f12022-09-27 11:30:14 -060038 "staging-RoboCrop",
George Engelbrechtd878ac32022-10-05 11:14:24 -060039 "staging_SourceCacheBuilder",
Greg Edelston5df44f12022-09-27 11:30:14 -060040 "staging-StarDoctor",
41)
42
43# CipdInstances are instance IDs, as found on the CIPD UI under "Instances".
44# For example: "M4HmuQVGbx8YQVkM61c6LnCHVFgpJsSy1bI4DpBjSTwC"
45CipdInstance = 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".
48CipdRef = 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.
51CipdVersion = typing.Union[CipdInstance, CipdRef]
52# GitHashes are the SHA of a Git commit (in the recipes repo).
53# For example: "5d185ee5339976575a282971eef16f590405217f"
54GitHash = typing.NewType('GitHash', str)
55
56
57def 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
81class 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
101def 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
123def setup():
124 """Prepare for main logic."""
125 git_remote_update()
126 print_cipd_versions_url()
127
128
129def 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
135def 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
144def 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
149def 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 Edelston7f723fb2022-10-03 11:08:56 -0600167 assert len(git_rev_lines) >= 1, lines
Greg Edelston5df44f12022-09-27 11:30:14 -0600168 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
175def 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
187def 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 Edelston81df4db2022-10-03 10:43:46 -0600192 instance_id = stdout[-1].split(':')[-1]
Greg Edelston5df44f12022-09-27 11:30:14 -0600193 assert len(instance_id.split()) == 1, instance_id
194 return CipdInstance(instance_id)
195
196
197def 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
232def 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
239def 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
258def 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
276def 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
283def 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
289def 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
305def 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 Edelston08321462022-10-04 16:01:58 -0600316 return p.stdout.strip()
Greg Edelston5df44f12022-09-27 11:30:14 -0600317
318
319def 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
327def 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
350if __name__ == '__main__':
351 main(sys.argv[1:])