1#!/usr/bin/env python
2# Copyright 2018 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""
7If should_use_hermetic_xcode.py emits "1", and the current toolchain is out of
8date:
9  * Downloads the hermetic mac toolchain
10    * Requires CIPD authentication. Run `cipd auth-login`, use Google account.
11  * Accepts the license.
12    * If xcode-select and xcodebuild are not passwordless in sudoers, requires
13      user interaction.
14  * Downloads standalone binaries from [a possibly different version of Xcode].
15
16The toolchain version can be overridden by setting MAC_TOOLCHAIN_REVISION with
17the full revision, e.g. 9A235.
18"""
19
20from __future__ import print_function
21
22import argparse
23import os
24import pkg_resources
25import platform
26import plistlib
27import shutil
28import subprocess
29import sys
30
31# To build these packages, see comments in build/xcode_binaries.yaml
32MAC_BINARIES_LABEL = 'infra_internal/ios/xcode/xcode_binaries/mac-amd64'
33MAC_BINARIES_TAG = {
34    # This contains binaries from Xcode 11.2.1, along with the 10.15 SDKs (aka
35    # 11B53).
36    'default': 'wXywrnOhzFxwLYlwO62UzRxVCjnu6DoSI2D2jrCd00gC',
37    # This contains binaries from Xcode 12.2 beta, along with the
38    # 11 SDK (aka 12B5018i).
39    'xcode_12_beta': 'WYCYb9qqIJtWJk4y23RGbsd7FLJPflS6weNRH3DnNLkC',
40}
41
42# The toolchain will not be downloaded if the minimum OS version is not met.
43# 17 is the major version number for macOS 10.13.
44# 9E145 (Xcode 9.3) only runs on 10.13.2 and newer.
45MAC_MINIMUM_OS_VERSION = {
46    'default': [17],  # macOS 10.13+
47    'xcode_12_beta': [19, 4],  # macOS 10.15.4+
48}
49
50BASE_DIR = os.path.abspath(os.path.dirname(__file__))
51TOOLCHAIN_ROOT = os.path.join(BASE_DIR, 'mac_files')
52TOOLCHAIN_BUILD_DIR = os.path.join(TOOLCHAIN_ROOT, 'Xcode.app')
53
54# Always integrity-check the entire SDK. Mac SDK packages are complex and often
55# hit edge cases in cipd (eg https://crbug.com/1033987,
56# https://crbug.com/915278), and generally when this happens it requires manual
57# intervention to fix.
58# Note the trailing \n!
59PARANOID_MODE = '$ParanoidMode CheckIntegrity\n'
60
61
62def PlatformMeetsHermeticXcodeRequirements(version):
63  if sys.platform != 'darwin':
64    return True
65  needed = MAC_MINIMUM_OS_VERSION[version]
66  major_version = [int(v) for v in platform.release().split('.')[:len(needed)]]
67  return major_version >= needed
68
69
70def _UseHermeticToolchain():
71  current_dir = os.path.dirname(os.path.realpath(__file__))
72  script_path = os.path.join(current_dir, 'mac/should_use_hermetic_xcode.py')
73  proc = subprocess.Popen([script_path, 'mac'], stdout=subprocess.PIPE)
74  return '1' in proc.stdout.readline()
75
76
77def RequestCipdAuthentication():
78  """Requests that the user authenticate to access Xcode CIPD packages."""
79
80  print('Access to Xcode CIPD package requires authentication.')
81  print('-----------------------------------------------------------------')
82  print()
83  print('You appear to be a Googler.')
84  print()
85  print('I\'m sorry for the hassle, but you may need to do a one-time manual')
86  print('authentication. Please run:')
87  print()
88  print('    cipd auth-login')
89  print()
90  print('and follow the instructions.')
91  print()
92  print('NOTE: Use your google.com credentials, not chromium.org.')
93  print()
94  print('-----------------------------------------------------------------')
95  print()
96  sys.stdout.flush()
97
98
99def PrintError(message):
100  # Flush buffers to ensure correct output ordering.
101  sys.stdout.flush()
102  sys.stderr.write(message + '\n')
103  sys.stderr.flush()
104
105
106def InstallXcodeBinaries(version, binaries_root=None):
107  """Installs the Xcode binaries needed to build Chrome and accepts the license.
108
109  This is the replacement for InstallXcode that installs a trimmed down version
110  of Xcode that is OS-version agnostic.
111  """
112  # First make sure the directory exists. It will serve as the cipd root. This
113  # also ensures that there will be no conflicts of cipd root.
114  if binaries_root is None:
115    binaries_root = os.path.join(TOOLCHAIN_ROOT, 'xcode_binaries')
116  if not os.path.exists(binaries_root):
117    os.makedirs(binaries_root)
118
119  # 'cipd ensure' is idempotent.
120  args = [
121      'cipd', 'ensure', '-root', binaries_root, '-ensure-file', '-'
122  ]
123
124  p = subprocess.Popen(
125      args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
126      stderr=subprocess.PIPE)
127  stdout, stderr = p.communicate(input=PARANOID_MODE + MAC_BINARIES_LABEL +
128                                 ' ' + MAC_BINARIES_TAG[version])
129  if p.returncode != 0:
130    print(stdout)
131    print(stderr)
132    RequestCipdAuthentication()
133    return 1
134
135  if sys.platform != 'darwin':
136    return 0
137
138  # Accept the license for this version of Xcode if it's newer than the
139  # currently accepted version.
140  cipd_xcode_version_plist_path = os.path.join(
141      binaries_root, 'Contents/version.plist')
142  cipd_xcode_version_plist = plistlib.readPlist(cipd_xcode_version_plist_path)
143  cipd_xcode_version = cipd_xcode_version_plist['CFBundleShortVersionString']
144
145  cipd_license_path = os.path.join(
146      binaries_root, 'Contents/Resources/LicenseInfo.plist')
147  cipd_license_plist = plistlib.readPlist(cipd_license_path)
148  cipd_license_version = cipd_license_plist['licenseID']
149
150  should_overwrite_license = True
151  current_license_path = '/Library/Preferences/com.apple.dt.Xcode.plist'
152  if os.path.exists(current_license_path):
153    current_license_plist = plistlib.readPlist(current_license_path)
154    xcode_version = current_license_plist['IDEXcodeVersionForAgreedToGMLicense']
155    if (pkg_resources.parse_version(xcode_version) >=
156        pkg_resources.parse_version(cipd_xcode_version)):
157      should_overwrite_license = False
158
159  if not should_overwrite_license:
160    return 0
161
162  # Use puppet's sudoers script to accept the license if its available.
163  license_accept_script = '/usr/local/bin/xcode_accept_license.py'
164  if os.path.exists(license_accept_script):
165    args = ['sudo', license_accept_script, '--xcode-version',
166            cipd_xcode_version, '--license-version', cipd_license_version]
167    subprocess.check_call(args)
168    return 0
169
170  # Otherwise manually accept the license. This will prompt for sudo.
171  print('Accepting new Xcode license. Requires sudo.')
172  sys.stdout.flush()
173  args = ['sudo', 'defaults', 'write', current_license_path,
174          'IDEXcodeVersionForAgreedToGMLicense', cipd_xcode_version]
175  subprocess.check_call(args)
176  args = ['sudo', 'defaults', 'write', current_license_path,
177          'IDELastGMLicenseAgreedTo', cipd_license_version]
178  subprocess.check_call(args)
179  args = ['sudo', 'plutil', '-convert', 'xml1', current_license_path]
180  subprocess.check_call(args)
181
182  return 0
183
184
185def main():
186  if not _UseHermeticToolchain():
187    print('Skipping Mac toolchain installation for mac')
188    return 0
189
190  parser = argparse.ArgumentParser(description='Download hermetic Xcode.')
191  parser.add_argument('--xcode-version',
192                      choices=('default', 'xcode_12_beta'),
193                      default='default')
194  args = parser.parse_args()
195
196  if not PlatformMeetsHermeticXcodeRequirements(args.xcode_version):
197    print('OS version does not support toolchain.')
198    return 0
199
200  return InstallXcodeBinaries(args.xcode_version)
201
202
203if __name__ == '__main__':
204  sys.exit(main())
205