nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 1 | # Copyright 2016 The LUCI Authors. All rights reserved. |
| 2 | # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 | # that can be found in the LICENSE file. |
| 4 | |
| 5 | """Fetches CIPD client and installs packages.""" |
| 6 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 7 | import contextlib |
| 8 | import hashlib |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 9 | import json |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 10 | import logging |
| 11 | import optparse |
| 12 | import os |
| 13 | import platform |
Takuto Ikuta | 62cdb32 | 2021-11-17 00:47:55 +0000 | [diff] [blame] | 14 | import re |
| 15 | import shutil |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 16 | import sys |
| 17 | import tempfile |
| 18 | import time |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 19 | import urllib.parse |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 20 | |
| 21 | from utils import file_path |
| 22 | from utils import fs |
| 23 | from utils import net |
| 24 | from utils import subprocess42 |
| 25 | from utils import tools |
Marc-Antoine Ruel | 34f5f28 | 2018-05-16 16:04:31 -0400 | [diff] [blame] | 26 | |
Justin Luong | 97eda6f | 2022-08-23 01:29:16 +0000 | [diff] [blame] | 27 | import errors |
Marc-Antoine Ruel | 34f5f28 | 2018-05-16 16:04:31 -0400 | [diff] [blame] | 28 | import local_caching |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 29 | |
| 30 | |
| 31 | # .exe on Windows. |
| 32 | EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else '' |
| 33 | |
Junji Watanabe | 4b890ef | 2020-09-16 01:43:27 +0000 | [diff] [blame] | 34 | _DEFAULT_CIPD_SERVER = 'https://chrome-infra-packages.appspot.com' |
| 35 | |
| 36 | _DEFAULT_CIPD_CLIENT_PACKAGE = 'infra/tools/cipd/${platform}' |
| 37 | |
| 38 | _DEFAULT_CIPD_CLIENT_VERSION = 'latest' |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 39 | |
iannucci | 4d7792a | 2017-03-10 10:30:56 -0800 | [diff] [blame] | 40 | if sys.platform == 'win32': |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 41 | |
iannucci | 4d7792a | 2017-03-10 10:30:56 -0800 | [diff] [blame] | 42 | def _ensure_batfile(client_path): |
| 43 | base, _ = os.path.splitext(client_path) |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 44 | with open(base + ".bat", 'w') as f: |
iannucci | 4d7792a | 2017-03-10 10:30:56 -0800 | [diff] [blame] | 45 | f.write('\n'.join([ # python turns \n into CRLF |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 46 | '@set CIPD="%~dp0cipd.exe"', '@shift', '@%CIPD% %*' |
iannucci | 4d7792a | 2017-03-10 10:30:56 -0800 | [diff] [blame] | 47 | ])) |
| 48 | else: |
| 49 | def _ensure_batfile(_client_path): |
| 50 | pass |
| 51 | |
| 52 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 53 | class Error(Exception): |
| 54 | """Raised on CIPD errors.""" |
| 55 | |
| 56 | |
| 57 | def add_cipd_options(parser): |
| 58 | group = optparse.OptionGroup(parser, 'CIPD') |
| 59 | group.add_option( |
vadimsh | 902948e | 2017-01-20 15:57:32 -0800 | [diff] [blame] | 60 | '--cipd-enabled', |
Ye Kuang | fff1e50 | 2020-07-13 13:21:57 +0000 | [diff] [blame] | 61 | help='Enable CIPD client bootstrap. Implied by --cipd-package. Cannot ' |
| 62 | 'turn this off while specifying --cipd-package', |
| 63 | default=True) |
vadimsh | 902948e | 2017-01-20 15:57:32 -0800 | [diff] [blame] | 64 | group.add_option( |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 65 | '--cipd-server', |
vadimsh | 902948e | 2017-01-20 15:57:32 -0800 | [diff] [blame] | 66 | help='URL of the CIPD server. ' |
Ye Kuang | 0a128a8 | 2020-06-26 08:57:31 +0000 | [diff] [blame] | 67 | 'Only relevant with --cipd-enabled or --cipd-package.', |
Junji Watanabe | 4b890ef | 2020-09-16 01:43:27 +0000 | [diff] [blame] | 68 | default=_DEFAULT_CIPD_SERVER) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 69 | group.add_option( |
| 70 | '--cipd-client-package', |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 71 | help='Package name of CIPD client with optional parameters described in ' |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 72 | '--cipd-package help. ' |
| 73 | 'Only relevant with --cipd-enabled or --cipd-package. ' |
| 74 | 'Default: "%default"', |
Junji Watanabe | 4b890ef | 2020-09-16 01:43:27 +0000 | [diff] [blame] | 75 | default=_DEFAULT_CIPD_CLIENT_PACKAGE) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 76 | group.add_option( |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 77 | '--cipd-client-version', |
| 78 | help='Version of CIPD client. ' |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 79 | 'Only relevant with --cipd-enabled or --cipd-package. ' |
| 80 | 'Default: "%default"', |
Junji Watanabe | 4b890ef | 2020-09-16 01:43:27 +0000 | [diff] [blame] | 81 | default=_DEFAULT_CIPD_CLIENT_VERSION) |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 82 | group.add_option( |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 83 | '--cipd-package', |
| 84 | dest='cipd_packages', |
| 85 | help='A CIPD package to install. ' |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 86 | 'Format is "<path>:<package_name>:<version>". ' |
| 87 | '"path" is installation directory relative to run_dir, ' |
| 88 | 'defaults to ".". ' |
| 89 | '"package_name" may have ${platform} parameter: it will be ' |
| 90 | 'expanded to "<os>-<architecture>". ' |
| 91 | 'The option can be specified multiple times.', |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 92 | action='append', |
| 93 | default=[]) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 94 | group.add_option( |
| 95 | '--cipd-cache', |
| 96 | help='CIPD cache directory, separate from isolate cache. ' |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 97 | 'Only relevant with --cipd-enabled or --cipd-package. ' |
Vadim Shtayura | 087aab7 | 2023-01-13 00:38:11 +0000 | [diff] [blame^] | 98 | 'Default: "%default".', |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 99 | default='') |
| 100 | parser.add_option_group(group) |
| 101 | |
| 102 | |
| 103 | def validate_cipd_options(parser, options): |
| 104 | """Calls parser.error on first found error among cipd options.""" |
vadimsh | 902948e | 2017-01-20 15:57:32 -0800 | [diff] [blame] | 105 | if not options.cipd_enabled: |
Ye Kuang | fff1e50 | 2020-07-13 13:21:57 +0000 | [diff] [blame] | 106 | if options.cipd_packages: |
| 107 | parser.error('Cannot install CIPD packages when --cipd-enable=false') |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 108 | return |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 109 | |
| 110 | for pkg in options.cipd_packages: |
| 111 | parts = pkg.split(':', 2) |
| 112 | if len(parts) != 3: |
| 113 | parser.error('invalid package "%s": must have at least 2 colons' % pkg) |
| 114 | _path, name, version = parts |
| 115 | if not name: |
| 116 | parser.error('invalid package "%s": package name is not specified' % pkg) |
| 117 | if not version: |
| 118 | parser.error('invalid package "%s": version is not specified' % pkg) |
| 119 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 120 | if not options.cipd_server: |
vadimsh | 902948e | 2017-01-20 15:57:32 -0800 | [diff] [blame] | 121 | parser.error('cipd is enabled, --cipd-server is required') |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 122 | |
| 123 | if not options.cipd_client_package: |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 124 | parser.error( |
vadimsh | 902948e | 2017-01-20 15:57:32 -0800 | [diff] [blame] | 125 | 'cipd is enabled, --cipd-client-package is required') |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 126 | if not options.cipd_client_version: |
| 127 | parser.error( |
vadimsh | 902948e | 2017-01-20 15:57:32 -0800 | [diff] [blame] | 128 | 'cipd is enabled, --cipd-client-version is required') |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 129 | |
| 130 | |
Junji Watanabe | ab2102a | 2022-01-12 01:44:04 +0000 | [diff] [blame] | 131 | class CipdClient: |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 132 | """Installs packages.""" |
| 133 | |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 134 | def __init__(self, binary_path, package_name, instance_id, service_url): |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 135 | """Initializes CipdClient. |
| 136 | |
| 137 | Args: |
| 138 | binary_path (str): path to the CIPD client binary. |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 139 | package_name (str): the CIPD package name for the client itself. |
| 140 | instance_id (str): the CIPD instance_id for the client itself. |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 141 | service_url (str): if not None, URL of the CIPD backend that overrides |
| 142 | the default one. |
| 143 | """ |
| 144 | self.binary_path = binary_path |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 145 | self.package_name = package_name |
| 146 | self.instance_id = instance_id |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 147 | self.service_url = service_url |
| 148 | |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 149 | def ensure(self, |
| 150 | site_root, |
| 151 | packages, |
| 152 | cache_dir=None, |
| 153 | tmp_dir=None, |
| 154 | timeout=None): |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 155 | """Ensures that packages installed in |site_root| equals |packages| set. |
| 156 | |
| 157 | Blocking call. |
| 158 | |
| 159 | Args: |
| 160 | site_root (str): where to install packages. |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 161 | packages: dict of subdir -> list of (package_template, version) tuples. |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 162 | cache_dir (str): if set, cache dir for cipd binary own cache. |
| 163 | Typically contains packages and tags. |
| 164 | tmp_dir (str): if not None, dir for temp files. |
| 165 | timeout (int): if not None, timeout in seconds for this function to run. |
| 166 | |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 167 | Returns: |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 168 | Pinned packages in the form of {subdir: [(package_name, package_id)]}, |
| 169 | which correspond 1:1 with the input packages argument. |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 170 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 171 | Raises: |
| 172 | Error if could not install packages or timed out. |
| 173 | """ |
| 174 | timeoutfn = tools.sliding_timeout(timeout) |
| 175 | logging.info('Installing packages %r into %s', packages, site_root) |
| 176 | |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 177 | ensure_file_handle, ensure_file_path = tempfile.mkstemp( |
Junji Watanabe | 53d3188 | 2022-01-13 07:58:00 +0000 | [diff] [blame] | 178 | dir=tmp_dir, prefix='cipd-ensure-file-', suffix='.txt') |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 179 | json_out_file_handle, json_file_path = tempfile.mkstemp( |
Junji Watanabe | 53d3188 | 2022-01-13 07:58:00 +0000 | [diff] [blame] | 180 | dir=tmp_dir, prefix='cipd-ensure-result-', suffix='.json') |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 181 | os.close(json_out_file_handle) |
| 182 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 183 | try: |
| 184 | try: |
Marc-Antoine Ruel | 04903a3 | 2019-10-09 21:09:25 +0000 | [diff] [blame] | 185 | for subdir, pkgs in sorted(packages.items()): |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 186 | if '\n' in subdir: |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 187 | raise Error('Could not install packages; subdir %r contains newline' |
| 188 | % subdir) |
tikuta | ddc3ccb | 2020-07-07 12:36:39 +0000 | [diff] [blame] | 189 | os.write(ensure_file_handle, ('@Subdir %s\n' % (subdir,)).encode()) |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 190 | for pkg, version in pkgs: |
tikuta | ddc3ccb | 2020-07-07 12:36:39 +0000 | [diff] [blame] | 191 | os.write(ensure_file_handle, ('%s %s\n' % (pkg, version)).encode()) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 192 | finally: |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 193 | os.close(ensure_file_handle) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 194 | |
| 195 | cmd = [ |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 196 | self.binary_path, |
| 197 | 'ensure', |
| 198 | '-root', |
| 199 | site_root, |
| 200 | '-ensure-file', |
| 201 | ensure_file_path, |
| 202 | '-verbose', # this is safe because cipd-ensure does not print a lot |
| 203 | '-json-output', |
| 204 | json_file_path, |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 205 | ] |
| 206 | if cache_dir: |
| 207 | cmd += ['-cache-dir', cache_dir] |
| 208 | if self.service_url: |
| 209 | cmd += ['-service-url', self.service_url] |
| 210 | |
| 211 | logging.debug('Running %r', cmd) |
Junji Watanabe | 88647c6 | 2021-05-11 03:41:10 +0000 | [diff] [blame] | 212 | kwargs = {} |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 213 | kwargs['encoding'] = 'utf-8' |
| 214 | kwargs['errors'] = 'backslashreplace' |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 215 | process = subprocess42.Popen( |
Junji Watanabe | bc5a7b6 | 2021-04-23 08:40:11 +0000 | [diff] [blame] | 216 | cmd, |
| 217 | stdout=subprocess42.PIPE, |
| 218 | stderr=subprocess42.PIPE, |
Junji Watanabe | 88647c6 | 2021-05-11 03:41:10 +0000 | [diff] [blame] | 219 | universal_newlines=True, |
| 220 | **kwargs) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 221 | output = [] |
| 222 | for pipe_name, line in process.yield_any_line(timeout=0.1): |
| 223 | to = timeoutfn() |
| 224 | if to is not None and to <= 0: |
| 225 | raise Error( |
| 226 | 'Could not install packages; took more than %d seconds' % timeout) |
| 227 | if not pipe_name: |
| 228 | # stdout or stderr was closed, but yield_any_line still may have |
| 229 | # something to yield. |
| 230 | continue |
| 231 | output.append(line) |
| 232 | if pipe_name == 'stderr': |
| 233 | logging.debug('cipd client: %s', line) |
| 234 | else: |
| 235 | logging.info('cipd client: %s', line) |
| 236 | |
| 237 | exit_code = process.wait(timeout=timeoutfn()) |
Justin Luong | 97eda6f | 2022-08-23 01:29:16 +0000 | [diff] [blame] | 238 | |
| 239 | ensure_result = {} |
| 240 | if os.path.exists(json_file_path): |
| 241 | with open(json_file_path) as jfile: |
| 242 | result_json = json.load(jfile) |
| 243 | ensure_result = result_json['result'] |
| 244 | status = result_json.get('error_code') |
| 245 | if status in ('auth_error', 'bad_argument_error', |
| 246 | 'invalid_version_error', 'stale_error', |
| 247 | 'hash_mismatch_error'): |
| 248 | details = result_json.get('error_details') |
| 249 | cipd_package = cipd_version = cipd_subdir = None |
| 250 | if details: |
| 251 | cipd_package = details.get('package') |
| 252 | cipd_version = details.get('version') |
| 253 | cipd_subdir = details.get('subdir') |
| 254 | raise errors.NonRecoverableCipdException(status, cipd_package, |
| 255 | cipd_subdir, cipd_version) |
| 256 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 257 | if exit_code != 0: |
| 258 | raise Error( |
| 259 | 'Could not install packages; exit code %d\noutput:%s' % ( |
| 260 | exit_code, '\n'.join(output))) |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 261 | return { |
Justin Luong | 97eda6f | 2022-08-23 01:29:16 +0000 | [diff] [blame] | 262 | subdir: [(x['package'], x['instance_id']) for x in pins] |
| 263 | for subdir, pins in ensure_result.items() |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 264 | } |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 265 | finally: |
iannucci | b58d10d | 2017-03-18 02:00:25 -0700 | [diff] [blame] | 266 | fs.remove(ensure_file_path) |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 267 | fs.remove(json_file_path) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 268 | |
| 269 | |
| 270 | def get_platform(): |
| 271 | """Returns ${platform} parameter value. |
| 272 | |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 273 | The logic is similar to |
| 274 | https://chromium.googlesource.com/chromium/tools/build/+/6c5c7e9c/scripts/slave/infra_platform.py |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 275 | """ |
| 276 | # linux, mac or windows. |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 277 | os_name = { |
Takuto Ikuta | 4150403 | 2019-10-29 12:23:57 +0000 | [diff] [blame] | 278 | 'darwin': 'mac', |
Takuto Ikuta | 4150403 | 2019-10-29 12:23:57 +0000 | [diff] [blame] | 279 | 'linux': 'linux', |
| 280 | 'win32': 'windows', |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 281 | }.get(sys.platform) |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 282 | if not os_name: |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 283 | raise Error('Unknown OS: %s' % sys.platform) |
| 284 | |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 285 | # Normalize machine architecture. Some architectures are identical or |
| 286 | # compatible with others. We collapse them into one. |
| 287 | arch = platform.machine().lower() |
Junji Watanabe | 7b0cb6c | 2021-10-28 08:42:31 +0000 | [diff] [blame] | 288 | if arch in ('arm64', 'aarch64'): |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 289 | arch = 'arm64' |
| 290 | elif arch.startswith('armv') and arch.endswith('l'): |
| 291 | # 32-bit ARM: Standardize on ARM v6 baseline. |
| 292 | arch = 'armv6l' |
| 293 | elif arch in ('amd64', 'x86_64'): |
| 294 | arch = 'amd64' |
Vadim Shtayura | 5059a05 | 2017-10-19 13:04:50 -0700 | [diff] [blame] | 295 | elif arch in ('i386', 'i686', 'x86'): |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 296 | arch = '386' |
Junji Watanabe | 41a770c | 2021-07-26 23:44:54 +0000 | [diff] [blame] | 297 | elif not arch and os_name == 'windows': |
| 298 | # On some 32bit Windows7, platform.machine() returns None. |
| 299 | # Fallback to 386 in that case. |
| 300 | logging.warning('platform.machine() returns None. ' |
| 301 | 'Use \'386\' as CPU architecture.') |
| 302 | arch = '386' |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 303 | |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 304 | # If using a 32-bit python on x86_64 kernel on Linux, "downgrade" the arch to |
| 305 | # 32-bit too (this is the bitness of the userland). |
| 306 | python_bits = 64 if sys.maxsize > 2**32 else 32 |
| 307 | if os_name == 'linux' and arch == 'amd64' and python_bits == 32: |
| 308 | arch = '386' |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 309 | |
Vadim Shtayura | 8f863a4 | 2017-10-18 19:23:15 -0700 | [diff] [blame] | 310 | return '%s-%s' % (os_name, arch) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 311 | |
| 312 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 313 | def _check_response(res, fmt, *args): |
| 314 | """Raises Error if response is bad.""" |
| 315 | if not res: |
| 316 | raise Error('%s: no response' % (fmt % args)) |
| 317 | |
| 318 | if res.get('status') != 'SUCCESS': |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 319 | raise Error('%s: %s' % (fmt % args, res.get('error_message') or |
| 320 | 'status is %s' % res.get('status'))) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 321 | |
| 322 | |
| 323 | def resolve_version(cipd_server, package_name, version, timeout=None): |
| 324 | """Resolves a package instance version (e.g. a tag) to an instance id.""" |
| 325 | url = '%s/_ah/api/repo/v1/instance/resolve?%s' % ( |
| 326 | cipd_server, |
Marc-Antoine Ruel | ad8cabe | 2019-10-10 23:24:26 +0000 | [diff] [blame] | 327 | urllib.parse.urlencode({ |
| 328 | 'package_name': package_name, |
| 329 | 'version': version, |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 330 | })) |
| 331 | res = net.url_read_json(url, timeout=timeout) |
| 332 | _check_response(res, 'Could not resolve version %s:%s', package_name, version) |
| 333 | instance_id = res.get('instance_id') |
| 334 | if not instance_id: |
| 335 | raise Error('Invalid resolveVersion response: no instance id') |
| 336 | return instance_id |
| 337 | |
| 338 | |
| 339 | def get_client_fetch_url(service_url, package_name, instance_id, timeout=None): |
| 340 | """Returns a fetch URL of CIPD client binary contents. |
| 341 | |
| 342 | Raises: |
| 343 | Error if cannot retrieve fetch URL. |
| 344 | """ |
| 345 | # Fetch the URL of the binary from CIPD backend. |
Marc-Antoine Ruel | ad8cabe | 2019-10-10 23:24:26 +0000 | [diff] [blame] | 346 | url = '%s/_ah/api/repo/v1/client?%s' % (service_url, |
| 347 | urllib.parse.urlencode({ |
| 348 | 'package_name': package_name, |
| 349 | 'instance_id': instance_id, |
| 350 | })) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 351 | res = net.url_read_json(url, timeout=timeout) |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 352 | _check_response(res, 'Could not fetch CIPD client %s:%s', package_name, |
| 353 | instance_id) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 354 | fetch_url = res.get('client_binary', {}).get('fetch_url') |
| 355 | if not fetch_url: |
| 356 | raise Error('Invalid fetchClientBinary response: no fetch_url') |
| 357 | return fetch_url |
| 358 | |
| 359 | |
| 360 | def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn): |
| 361 | """Fetches cipd binary to |disk_cache|. |
| 362 | |
| 363 | Retries requests with exponential back-off. |
| 364 | |
| 365 | Raises: |
| 366 | Error if could not fetch content. |
| 367 | """ |
| 368 | sleep_time = 1 |
Marc-Antoine Ruel | 0fdee22 | 2019-10-10 14:42:40 +0000 | [diff] [blame] | 369 | for attempt in range(5): |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 370 | if attempt > 0: |
| 371 | if timeoutfn() is not None and timeoutfn() < sleep_time: |
| 372 | raise Error('Could not fetch CIPD client: timeout') |
| 373 | logging.warning('Will retry to fetch CIPD client in %ds', sleep_time) |
| 374 | time.sleep(sleep_time) |
| 375 | sleep_time *= 2 |
| 376 | |
| 377 | try: |
| 378 | res = net.url_open(fetch_url, timeout=timeoutfn()) |
| 379 | if res: |
| 380 | disk_cache.write(instance_id, res.iter_content(64 * 1024)) |
| 381 | return |
| 382 | except net.TimeoutError as ex: |
Junji Watanabe | ab2102a | 2022-01-12 01:44:04 +0000 | [diff] [blame] | 383 | raise Error('Could not fetch CIPD client: %s' % ex) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 384 | except net.NetError as ex: |
Junji Watanabe | 38b28b0 | 2020-04-23 10:23:30 +0000 | [diff] [blame] | 385 | logging.warning('Could not fetch CIPD client on attempt #%d: %s', |
| 386 | attempt + 1, ex) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 387 | |
| 388 | raise Error('Could not fetch CIPD client after 5 retries') |
| 389 | |
| 390 | |
Takuto Ikuta | 62cdb32 | 2021-11-17 00:47:55 +0000 | [diff] [blame] | 391 | def _is_valid_hash(value): |
| 392 | """Returns if the value is a valid hash for the corresponding algorithm.""" |
| 393 | size = 2 * hashlib.sha1().digest_size |
| 394 | return bool(re.match(r'^[a-fA-F0-9]{%d}$' % size, value)) |
| 395 | |
| 396 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 397 | @contextlib.contextmanager |
Junji Watanabe | 4b890ef | 2020-09-16 01:43:27 +0000 | [diff] [blame] | 398 | def get_client(cache_dir, |
| 399 | service_url=_DEFAULT_CIPD_SERVER, |
| 400 | package_template=_DEFAULT_CIPD_CLIENT_PACKAGE, |
| 401 | version=_DEFAULT_CIPD_CLIENT_VERSION, |
| 402 | timeout=None): |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 403 | """Returns a context manager that yields a CipdClient. A blocking call. |
| 404 | |
vadimsh | 232f5a8 | 2017-01-20 19:23:44 -0800 | [diff] [blame] | 405 | Upon exit from the context manager, the client binary may be deleted |
| 406 | (if the internal cache is full). |
| 407 | |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 408 | Args: |
vadimsh | 232f5a8 | 2017-01-20 19:23:44 -0800 | [diff] [blame] | 409 | service_url (str): URL of the CIPD backend. |
Marc-Antoine Ruel | 6f348a2 | 2017-12-06 12:47:37 -0500 | [diff] [blame] | 410 | package_template (str): package name template of the CIPD client. |
vadimsh | 232f5a8 | 2017-01-20 19:23:44 -0800 | [diff] [blame] | 411 | version (str): version of CIPD client package. |
| 412 | cache_dir: directory to store instance cache, version cache |
| 413 | and a hardlink to the client binary. |
| 414 | timeout (int): if not None, timeout in seconds for this function. |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 415 | |
| 416 | Yields: |
| 417 | CipdClient. |
| 418 | |
| 419 | Raises: |
| 420 | Error if CIPD client version cannot be resolved or client cannot be fetched. |
| 421 | """ |
| 422 | timeoutfn = tools.sliding_timeout(timeout) |
| 423 | |
Marc-Antoine Ruel | 6f348a2 | 2017-12-06 12:47:37 -0500 | [diff] [blame] | 424 | # Package names are always lower case. |
| 425 | # TODO(maruel): Assert instead? |
| 426 | package_name = package_template.lower().replace('${platform}', get_platform()) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 427 | |
| 428 | # Resolve version to instance id. |
| 429 | # Is it an instance id already? They look like HEX SHA1. |
Takuto Ikuta | 62cdb32 | 2021-11-17 00:47:55 +0000 | [diff] [blame] | 430 | if _is_valid_hash(version): |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 431 | instance_id = version |
Vadim Shtayura | aaecc1c | 2017-10-19 11:36:42 -0700 | [diff] [blame] | 432 | elif ':' in version: # it's an immutable tag, cache the resolved version |
| 433 | # version_cache is {hash(package_name, tag) -> instance id} mapping. |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 434 | # It does not take a lot of disk space. |
Marc-Antoine Ruel | 2666d9c | 2018-05-18 13:52:02 -0400 | [diff] [blame] | 435 | version_cache = local_caching.DiskContentAddressedCache( |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 436 | os.path.join(cache_dir, 'versions'), |
Marc-Antoine Ruel | 34f5f28 | 2018-05-16 16:04:31 -0400 | [diff] [blame] | 437 | local_caching.CachePolicies( |
Marc-Antoine Ruel | 77d9378 | 2018-05-24 16:13:55 -0400 | [diff] [blame] | 438 | # 1GiB. |
Takuto Ikuta | 6e2ff96 | 2019-10-29 12:35:27 +0000 | [diff] [blame] | 439 | max_cache_size=1024 * 1024 * 1024, |
Marc-Antoine Ruel | 34f5f28 | 2018-05-16 16:04:31 -0400 | [diff] [blame] | 440 | min_free_space=0, |
| 441 | max_items=300, |
| 442 | # 3 weeks. |
Takuto Ikuta | 6e2ff96 | 2019-10-29 12:35:27 +0000 | [diff] [blame] | 443 | max_age_secs=21 * 24 * 60 * 60), |
maruel | e6fc938 | 2017-05-04 09:03:48 -0700 | [diff] [blame] | 444 | trim=True) |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 445 | # Convert (package_name, version) to a string that may be used as a |
| 446 | # filename in disk cache by hashing it. |
Takuto Ikuta | 922c864 | 2021-11-18 07:42:16 +0000 | [diff] [blame] | 447 | version_digest = hashlib.sha256( |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 448 | ('%s\n%s' % (package_name, version)).encode()).hexdigest() |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 449 | try: |
| 450 | with version_cache.getfileobj(version_digest) as f: |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 451 | instance_id = f.read().decode() |
Takuto Ikuta | b70dd7f | 2021-09-06 09:42:53 +0000 | [diff] [blame] | 452 | logging.info("instance_id %s", instance_id) |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 453 | except local_caching.CacheMiss: |
Takuto Ikuta | b70dd7f | 2021-09-06 09:42:53 +0000 | [diff] [blame] | 454 | logging.info("version_cache miss for %s", version_digest) |
| 455 | instance_id = '' |
| 456 | |
| 457 | if not instance_id: |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 458 | instance_id = resolve_version( |
| 459 | service_url, package_name, version, timeout=timeoutfn()) |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 460 | version_cache.write(version_digest, [instance_id.encode()]) |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 461 | version_cache.trim() |
Vadim Shtayura | aaecc1c | 2017-10-19 11:36:42 -0700 | [diff] [blame] | 462 | else: # it's a ref, hit the backend |
iannucci | 6fd57d2 | 2016-08-30 17:02:20 -0700 | [diff] [blame] | 463 | instance_id = resolve_version( |
| 464 | service_url, package_name, version, timeout=timeoutfn()) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 465 | |
| 466 | # instance_cache is {instance_id -> client binary} mapping. |
| 467 | # It is bounded by 5 client versions. |
Marc-Antoine Ruel | 2666d9c | 2018-05-18 13:52:02 -0400 | [diff] [blame] | 468 | instance_cache = local_caching.DiskContentAddressedCache( |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 469 | os.path.join(cache_dir, 'clients'), |
Takuto Ikuta | 6e2ff96 | 2019-10-29 12:35:27 +0000 | [diff] [blame] | 470 | local_caching.CachePolicies( |
| 471 | # 1GiB. |
| 472 | max_cache_size=1024 * 1024 * 1024, |
| 473 | min_free_space=0, |
| 474 | max_items=10, |
| 475 | # 3 weeks. |
| 476 | max_age_secs=21 * 24 * 60 * 60), |
maruel | e6fc938 | 2017-05-04 09:03:48 -0700 | [diff] [blame] | 477 | trim=True) |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 478 | if instance_id not in instance_cache: |
| 479 | logging.info('Fetching CIPD client %s:%s', package_name, instance_id) |
| 480 | fetch_url = get_client_fetch_url( |
| 481 | service_url, package_name, instance_id, timeout=timeoutfn()) |
| 482 | _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn) |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 483 | |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 484 | # A single host can run multiple swarming bots, but they cannot share same |
| 485 | # root bot directory. Thus, it is safe to use the same name for the binary. |
Junji Watanabe | 7a677e9 | 2022-01-13 06:07:31 +0000 | [diff] [blame] | 486 | cipd_bin_dir = os.path.join(cache_dir, 'bin') |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 487 | binary_path = os.path.join(cipd_bin_dir, 'cipd' + EXECUTABLE_SUFFIX) |
| 488 | if fs.isfile(binary_path): |
| 489 | # TODO(maruel): Do not unconditionally remove the binary. |
Takuto Ikuta | 296ed05 | 2019-11-29 01:47:20 +0000 | [diff] [blame] | 490 | try: |
| 491 | file_path.remove(binary_path) |
| 492 | except WindowsError: # pylint: disable=undefined-variable |
| 493 | # See whether cipd.exe is running for crbug.com/1028781 |
| 494 | ret = subprocess42.call(['tasklist.exe']) |
| 495 | if ret: |
| 496 | logging.error('tasklist returns non-zero: %d', ret) |
| 497 | raise |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 498 | else: |
| 499 | file_path.ensure_tree(cipd_bin_dir) |
tansell | 9e04a8d | 2016-07-28 09:31:59 -0700 | [diff] [blame] | 500 | |
Takuto Ikuta | 62cdb32 | 2021-11-17 00:47:55 +0000 | [diff] [blame] | 501 | with instance_cache.getfileobj(instance_id) as f, fs.open(binary_path, |
| 502 | 'wb') as dest: |
| 503 | shutil.copyfileobj(f, dest) |
| 504 | fs.chmod(binary_path, 0o511) # -r-x--x--x |
nodir | be642ff | 2016-06-09 15:51:51 -0700 | [diff] [blame] | 505 | |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 506 | _ensure_batfile(binary_path) |
iannucci | 4d7792a | 2017-03-10 10:30:56 -0800 | [diff] [blame] | 507 | |
Marc-Antoine Ruel | e79ddbf | 2018-06-13 18:33:07 +0000 | [diff] [blame] | 508 | yield CipdClient( |
| 509 | binary_path, |
| 510 | package_name=package_name, |
| 511 | instance_id=instance_id, |
| 512 | service_url=service_url) |
| 513 | instance_cache.trim() |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 514 | |
| 515 | |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 516 | def parse_package_args(packages): |
| 517 | """Parses --cipd-package arguments. |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 518 | |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 519 | Assumes |packages| were validated by validate_cipd_options. |
| 520 | |
| 521 | Returns: |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 522 | A list of [(path, package_name, version), ...] |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 523 | """ |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 524 | result = [] |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 525 | for pkg in packages: |
| 526 | path, name, version = pkg.split(':', 2) |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 527 | if not name: |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 528 | raise Error('Invalid package "%s": package name is not specified' % pkg) |
nodir | 90bc8dc | 2016-06-15 13:35:21 -0700 | [diff] [blame] | 529 | if not version: |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 530 | raise Error('Invalid package "%s": version is not specified' % pkg) |
iannucci | 96fcccc | 2016-08-30 15:52:22 -0700 | [diff] [blame] | 531 | result.append((path, name, version)) |
nodir | ff531b4 | 2016-06-23 13:05:06 -0700 | [diff] [blame] | 532 | return result |