Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 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 | |
| 7 __version__ = '0.1' | |
| 8 | |
| 9 import contextlib | |
| 10 import hashlib | |
| 11 import logging | |
| 12 import optparse | |
| 13 import os | |
| 14 import platform | |
| 15 import sys | |
| 16 import tempfile | |
| 17 import time | |
| 18 import urllib | |
| 19 | |
| 20 from utils import file_path | |
| 21 from utils import fs | |
| 22 from utils import net | |
| 23 from utils import subprocess42 | |
| 24 from utils import tools | |
| 25 import isolated_format | |
| 26 import isolateserver | |
| 27 | |
| 28 | |
| 29 # .exe on Windows. | |
| 30 EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else '' | |
| 31 | |
| 32 | |
| 33 class Error(Exception): | |
| 34 """Raised on CIPD errors.""" | |
| 35 | |
| 36 | |
| 37 def add_cipd_options(parser): | |
| 38 group = optparse.OptionGroup(parser, 'CIPD') | |
| 39 group.add_option( | |
| 40 '--cipd-server', | |
| 41 help='URL of the CIPD server. Only relevant with --cipd-package.') | |
| 42 group.add_option( | |
| 43 '--cipd-client-package', | |
| 44 help='Package of CIPD client. See --cipd-package for format. ' | |
| 45 'Only relevant with --cipd-package. ' | |
| 46 'Default: "%default"', | |
| 47 default='infra/tools/cipd/${platform}:latest') | |
| 48 group.add_option( | |
| 49 '--cipd-package', | |
| 50 help='CIPD package to install. ' | |
| 51 'Format: "<package name template>:<version>". ' | |
| 52 'Package name template is a CIPD package name with optional ' | |
| 53 '${platform} and/or ${os_ver} parameters. ' | |
| 54 '${platform} will be expanded to "<os>-<architecture>" and ' | |
| 55 '${os_ver} will be expanded to OS version name. ' | |
| 56 'This option can be specified more than once.', | |
| 57 action='append') | |
| 58 group.add_option( | |
| 59 '--cipd-cache', | |
| 60 help='CIPD cache directory, separate from isolate cache. ' | |
| 61 'Only relevant with --cipd-package. ' | |
| 62 'Default: "%default".', | |
| 63 default='') | |
| 64 parser.add_option_group(group) | |
| 65 | |
| 66 | |
| 67 def validate_cipd_options(parser, options): | |
| 68 """Calls parser.error on first found error among cipd options.""" | |
| 69 if not options.cipd_package: | |
| 70 return | |
| 71 for p in options.cipd_package: | |
| 72 try: | |
| 73 parse_package(p) | |
| 74 except ValueError as ex: | |
| 75 parser.error('Invalid cipd package %r: %s' % (p, ex)) | |
| 76 | |
| 77 if not options.cipd_server: | |
| 78 parser.error('--cipd-package requires non-empty --cipd-server') | |
| 79 | |
| 80 if not options.cipd_client_package: | |
| 81 parser.error('--cipd-package requires non-empty --cipd-client-package') | |
| 82 try: | |
| 83 parse_package(options.cipd_client_package) | |
| 84 except ValueError as ex: | |
| 85 parser.error( | |
| 86 'Invalid cipd client package %r: %s' % | |
| 87 (options.cipd_client_package, ex)) | |
| 88 | |
| 89 | |
| 90 class CipdClient(object): | |
| 91 """Installs packages.""" | |
| 92 | |
| 93 def __init__(self, binary_path, service_url=None): | |
| 94 """Initializes CipdClient. | |
| 95 | |
| 96 Args: | |
| 97 binary_path (str): path to the CIPD client binary. | |
| 98 service_url (str): if not None, URL of the CIPD backend that overrides | |
| 99 the default one. | |
| 100 """ | |
| 101 self.binary_path = binary_path | |
| 102 self.service_url = service_url | |
| 103 | |
| 104 def ensure( | |
| 105 self, site_root, packages, cache_dir=None, tmp_dir=None, timeout=None): | |
| 106 """Ensures that packages installed in |site_root| equals |packages| set. | |
| 107 | |
| 108 Blocking call. | |
| 109 | |
| 110 Args: | |
| 111 site_root (str): where to install packages. | |
| 112 packages (str): list of packages to install, parsable by parse_pacakge(). | |
| 113 cache_dir (str): if set, cache dir for cipd binary own cache. | |
| 114 Typically contains packages and tags. | |
| 115 tmp_dir (str): if not None, dir for temp files. | |
| 116 timeout (int): if not None, timeout in seconds for this function to run. | |
| 117 | |
| 118 Raises: | |
| 119 Error if could not install packages or timed out. | |
| 120 """ | |
| 121 timeoutfn = tools.sliding_timeout(timeout) | |
| 122 logging.info('Installing packages %r into %s', packages, site_root) | |
| 123 | |
| 124 list_file_handle, list_file_path = tempfile.mkstemp( | |
| 125 dir=tmp_dir, prefix=u'cipd-ensure-list-', suffix='.txt') | |
| 126 list_file_closed = False | |
|
M-A Ruel
2016/06/09 16:21:04
not needed
nodir
2016/06/09 16:36:46
Done
| |
| 127 try: | |
| 128 for p in packages: | |
| 129 pkg, version = parse_package(p) | |
| 130 pkg = render_package_name_template(pkg) | |
| 131 os.write(list_file_handle, '%s %s\n' % (pkg, version)) | |
| 132 os.close(list_file_handle) | |
| 133 list_file_closed = True | |
|
M-A Ruel
2016/06/09 16:21:03
list_file_handle = None
but in practice I'd prefe
nodir
2016/06/09 16:36:46
Done.
| |
| 134 | |
| 135 cmd = [ | |
| 136 self.binary_path, 'ensure', | |
| 137 '-root', site_root, | |
| 138 '-list', list_file_path, | |
| 139 '-verbose', # this is safe because cipd-ensure does not print a lot | |
| 140 ] | |
| 141 if cache_dir: | |
| 142 cmd += ['-cache-dir', cache_dir] | |
| 143 if self.service_url: | |
| 144 cmd += ['-service-url', self.service_url] | |
| 145 | |
| 146 logging.debug('Running %r', cmd) | |
| 147 process = subprocess42.Popen( | |
| 148 cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE) | |
| 149 output = [] | |
| 150 for pipe_name, line in process.yield_any_line(timeout=0.1): | |
| 151 to = timeoutfn() | |
| 152 if to is not None and to <= 0: | |
| 153 raise Error( | |
| 154 'Could not install packages; took more than %d seconds' % timeout) | |
| 155 if not pipe_name: | |
| 156 # stdout or stderr was closed, but yield_any_line still may have | |
| 157 # something to yield. | |
| 158 continue | |
| 159 output.append(line) | |
| 160 if pipe_name == 'stderr': | |
| 161 logging.debug('cipd client: %s', line) | |
| 162 else: | |
| 163 logging.info('cipd client: %s', line) | |
| 164 | |
| 165 exit_code = process.wait(timeout=timeoutfn()) | |
| 166 if exit_code != 0: | |
| 167 raise Error( | |
| 168 'Could not install packages; exit code %d\noutput:%s' % ( | |
| 169 exit_code, '\n'.join(output))) | |
| 170 finally: | |
| 171 if not list_file_closed: | |
| 172 os.close(list_file_handle) | |
| 173 fs.remove(list_file_path) | |
| 174 | |
| 175 | |
| 176 def get_platform(): | |
| 177 """Returns ${platform} parameter value. | |
| 178 | |
| 179 Borrowed from | |
| 180 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204 | |
| 181 """ | |
| 182 # linux, mac or windows. | |
| 183 platform_variant = { | |
| 184 'darwin': 'mac', | |
| 185 'linux2': 'linux', | |
| 186 'win32': 'windows', | |
| 187 }.get(sys.platform) | |
| 188 if not platform_variant: | |
| 189 raise Error('Unknown OS: %s' % sys.platform) | |
| 190 | |
| 191 # amd64, 386, etc. | |
| 192 machine = platform.machine().lower() | |
| 193 platform_arch = { | |
| 194 'amd64': 'amd64', | |
| 195 'i386': '386', | |
| 196 'i686': '386', | |
| 197 'x86': '386', | |
| 198 'x86_64': 'amd64', | |
| 199 }.get(machine) | |
| 200 if not platform_arch: | |
| 201 if machine.startswith('arm'): | |
| 202 platform_arch = 'armv6l' | |
| 203 else: | |
| 204 platform_arch = 'amd64' if sys.maxsize > 2**32 else '386' | |
| 205 return '%s-%s' % (platform_variant, platform_arch) | |
| 206 | |
| 207 | |
| 208 def get_os_ver(): | |
| 209 """Returns ${os_ver} parameter value. | |
| 210 | |
| 211 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'. | |
| 212 | |
| 213 Borrowed from | |
| 214 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204 | |
| 215 """ | |
| 216 if sys.platform == 'darwin': | |
| 217 # platform.mac_ver()[0] is '10.9.5'. | |
| 218 dist = platform.mac_ver()[0].split('.') | |
| 219 return 'mac%s_%s' % (dist[0], dist[1]) | |
| 220 | |
| 221 if sys.platform == 'linux2': | |
| 222 # platform.linux_distribution() is ('Ubuntu', '14.04', ...). | |
| 223 dist = platform.linux_distribution() | |
| 224 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_')) | |
| 225 | |
| 226 if sys.platform == 'win32': | |
| 227 # platform.version() is '6.1.7601'. | |
| 228 dist = platform.version().split('.') | |
| 229 return 'win%s_%s' % (dist[0], dist[1]) | |
| 230 raise Error('Unknown OS: %s' % sys.platform) | |
| 231 | |
| 232 | |
| 233 def render_package_name_template(template): | |
| 234 """Expands template variables in a CIPD package name template.""" | |
| 235 return (template | |
| 236 .replace('${platform}', get_platform()) | |
| 237 .replace('${os_ver}', get_os_ver())) | |
| 238 | |
| 239 | |
| 240 def parse_package(package): | |
| 241 """Parses a package in --cipd-package format. | |
| 242 | |
| 243 Returns: | |
| 244 (package_name_template, version) tuple. | |
| 245 | |
| 246 Raises: | |
| 247 ValueError if package name or version is not specified. | |
| 248 """ | |
| 249 if not package: | |
| 250 raise ValueError('package is not specified') | |
| 251 parts = package.split(':', 1) | |
| 252 if len(parts) != 2: | |
| 253 raise ValueError('version is not specified') | |
| 254 return tuple(parts) | |
| 255 | |
| 256 | |
| 257 def _check_response(res, fmt, *args): | |
| 258 """Raises Error if response is bad.""" | |
| 259 if not res: | |
| 260 raise Error('%s: no response' % (fmt % args)) | |
| 261 | |
| 262 if res.get('status') != 'SUCCESS': | |
| 263 raise Error('%s: %s' % ( | |
| 264 fmt % args, | |
| 265 res.get('error_message') or 'status is %s' % res.get('status'))) | |
| 266 | |
| 267 | |
| 268 def resolve_version(cipd_server, package_name, version, timeout=None): | |
| 269 """Resolves a package instance version (e.g. a tag) to an instance id.""" | |
| 270 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % ( | |
| 271 cipd_server, | |
| 272 urllib.urlencode({ | |
| 273 'package_name': package_name, | |
| 274 'version': version, | |
| 275 })) | |
| 276 res = net.url_read_json(url, timeout=timeout) | |
| 277 _check_response(res, 'Could not resolve version %s:%s', package_name, version) | |
| 278 instance_id = res.get('instance_id') | |
| 279 if not instance_id: | |
| 280 raise Error('Invalid resolveVersion response: no instance id') | |
| 281 return instance_id | |
| 282 | |
| 283 | |
| 284 def get_client_fetch_url(service_url, package_name, instance_id, timeout=None): | |
| 285 """Returns a fetch URL of CIPD client binary contents. | |
| 286 | |
| 287 Raises: | |
| 288 Error if cannot retrieve fetch URL. | |
| 289 """ | |
| 290 # Fetch the URL of the binary from CIPD backend. | |
| 291 package_name = render_package_name_template(package_name) | |
| 292 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({ | |
| 293 'package_name': package_name, | |
| 294 'instance_id': instance_id, | |
| 295 })) | |
| 296 res = net.url_read_json(url, timeout=timeout) | |
| 297 _check_response( | |
| 298 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id) | |
| 299 fetch_url = res.get('client_binary', {}).get('fetch_url') | |
| 300 if not fetch_url: | |
| 301 raise Error('Invalid fetchClientBinary response: no fetch_url') | |
| 302 return fetch_url | |
| 303 | |
| 304 | |
| 305 def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn): | |
| 306 """Fetches cipd binary to |disk_cache|. | |
| 307 | |
| 308 Retries requests with exponential back-off. | |
| 309 | |
| 310 Raises: | |
| 311 Error if could not fetch content. | |
| 312 """ | |
| 313 sleep_time = 1 | |
| 314 for attempt in xrange(5): | |
| 315 if attempt > 0: | |
| 316 if timeoutfn() is not None and timeoutfn() < sleep_time: | |
| 317 raise Error('Could not fetch CIPD client: timeout') | |
| 318 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time) | |
| 319 time.sleep(sleep_time) | |
| 320 sleep_time *= 2 | |
| 321 | |
| 322 try: | |
| 323 res = net.url_open(fetch_url, timeout=timeoutfn()) | |
| 324 if res: | |
| 325 disk_cache.write(instance_id, res.iter_content(64 * 1024)) | |
| 326 except net.TimeoutError as ex: | |
| 327 raise Error('Could not fetch CIPD client: %s', ex) | |
| 328 except net.NetError as ex: | |
| 329 logging.warning( | |
| 330 'Could not fetch CIPD client on attempt #%d: %s', attempt + 1, ex) | |
| 331 | |
| 332 raise Error('Could not fetch CIPD client after 5 retries') | |
| 333 | |
| 334 | |
| 335 @contextlib.contextmanager | |
| 336 def get_client( | |
| 337 service_url, package_name, version, cache_dir, timeout=None): | |
| 338 """Returns a context manager that yields a CipdClient. A blocking call. | |
| 339 | |
| 340 Args: | |
| 341 service_url (str): URL of the CIPD backend. | |
| 342 package_name (str): package name template of the CIPD client. | |
| 343 version (str): version of CIPD client package. | |
| 344 cache_dir: directory to store instance cache, version cache | |
| 345 and a hardlink to the client binary. | |
| 346 timeout (int): if not None, timeout in seconds for this function. | |
| 347 | |
| 348 Yields: | |
| 349 CipdClient. | |
| 350 | |
| 351 Raises: | |
| 352 Error if CIPD client version cannot be resolved or client cannot be fetched. | |
| 353 """ | |
| 354 timeoutfn = tools.sliding_timeout(timeout) | |
| 355 | |
| 356 package_name = render_package_name_template(package_name) | |
| 357 | |
| 358 # Resolve version to instance id. | |
| 359 # Is it an instance id already? They look like HEX SHA1. | |
| 360 if isolated_format.is_valid_hash(version, hashlib.sha1): | |
| 361 instance_id = version | |
| 362 else: | |
| 363 # version_cache is {version_digest -> instance id} mapping. | |
| 364 # It does not take a lot of disk space. | |
| 365 version_cache = isolateserver.DiskCache( | |
| 366 unicode(os.path.join(cache_dir, 'versions')), | |
| 367 isolateserver.CachePolicies(0, 0, 300), | |
| 368 hashlib.sha1) | |
| 369 with version_cache: | |
| 370 # Convert |version| to a string that may be used as a filename in disk cac he | |
|
M-A Ruel
2016/06/09 16:21:03
wrap at 80 cols
nodir
2016/06/09 16:36:46
Done.
| |
| 371 # by hashing it. | |
| 372 version_digest = hashlib.sha1(version).hexdigest() | |
| 373 try: | |
| 374 instance_id = version_cache.read(version_digest) | |
| 375 except isolateserver.CacheMiss: | |
| 376 instance_id = resolve_version( | |
| 377 service_url, package_name, version, timeout=timeoutfn()) | |
| 378 version_cache.write(version_digest, instance_id) | |
| 379 | |
| 380 # instance_cache is {instance_id -> client binary} mapping. | |
| 381 # It is bounded by 5 client versions. | |
| 382 instance_cache = isolateserver.DiskCache( | |
| 383 unicode(os.path.join(cache_dir, 'clients')), | |
| 384 isolateserver.CachePolicies(0, 0, 5), | |
| 385 hashlib.sha1) | |
| 386 with instance_cache: | |
| 387 if instance_id not in instance_cache: | |
| 388 logging.info('Fetching CIPD client %s:%s', package_name, instance_id) | |
| 389 fetch_url = get_client_fetch_url( | |
| 390 service_url, package_name, instance_id, timeout=timeoutfn()) | |
| 391 _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn) | |
| 392 | |
| 393 # A single host can run multiple swarming bots, but ATM they do not share sa me | |
| 394 # root bot directory. Thus, it is safe to use the same name for the binary. | |
| 395 binary_path = unicode(os.path.join(cache_dir, 'cipd' + EXECUTABLE_SUFFIX)) | |
| 396 if fs.isfile(binary_path): | |
| 397 fs.unlink(binary_path) | |
| 398 instance_cache.hardlink(instance_id, binary_path, 0511) # -r-x--x--x | |
| 399 | |
| 400 yield CipdClient(binary_path) | |
| OLD | NEW |