Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(196)

Unified Diff: client/cipd.py

Issue 2037253002: run_isolated.py: install CIPD packages (Closed) Base URL: https://chromium.googlesource.com/external/github.com/luci/luci-py@master
Patch Set: Created 4 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: client/cipd.py
diff --git a/client/cipd.py b/client/cipd.py
new file mode 100644
index 0000000000000000000000000000000000000000..6fa93dbfcb3871bb03a4d0f4a159d6ef1f1c8b5b
--- /dev/null
+++ b/client/cipd.py
@@ -0,0 +1,356 @@
+# Copyright 2012 The LUCI Authors. All rights reserved.
M-A Ruel 2016/06/06 23:34:50 2012?
nodir 2016/06/07 18:46:35 Done.
+# Use of this source code is governed under the Apache License, Version 2.0
+# that can be found in the LICENSE file.
+
+"""Fetches CIPD client and installs packages."""
+
+__version__ = '0.1'
+
+import hashlib
+import logging
+import optparse
+import platform
+import sys
+import tempfile
+import urllib
+
+from utils import fs
+from utils import net
+from utils import subprocess42
+from utils import tools
+import isolated_format
+import isolateserver
+
+
+class Error(Exception):
+ """Raised on CIPD errors."""
+
+
+def add_cipd_options(parser):
+ group = optparse.OptionGroup(parser, 'CIPD')
+ group.add_option(
+ '--cipd-server',
+ help=(
M-A Ruel 2016/06/06 23:34:50 () are not needed for str concatenation
nodir 2016/06/07 18:46:35 Done.
+ 'URL of the CIPD server. Only relevant with --cipd-package. '
+ 'Default: "%default"'),
+ default='https://chrome-infra-packages.appspot.com')
+ group.add_option(
+ '--cipd-client-package',
+ help=(
+ 'Package of CIPD client. See --cipd-package for format. '
+ 'Only relevant with --cipd-package. '
+ 'Default: "%default"'),
+ default='infra/tools/cipd/${platform}:latest')
+ group.add_option(
+ '--cipd-package',
+ help=(
+ 'CIPD package to install. '
+ 'Format: "<package name template>:<version>". '
+ 'Package name template is a CIPD package name with optional '
+ '${platform} and/or ${os_ver} parameters. '
+ '${platform} will be expanded to "<os>-<architecture>" and '
+ '${os_ver} will be expanded to OS version name. '
+ 'This option can be specified more than once.'),
+ action='append')
+ group.add_option(
+ '--cipd-cache',
+ help=(
+ 'CIPD cache directory, separate from isolate cache. '
+ 'Only relevant with --cipd-package. '
+ 'Default: "%default".'))
+ parser.add_option_group(group)
+
+
+def validate_cipd_options(parser, options):
+ """Calls parser.error on first found error among cipd options."""
+ if not options.cipd_package:
+ return
+ for p in options.cipd_package:
+ try:
+ parse_package(p)
+ except ValueError as ex:
+ parser.error('Invalid cipd package %r: %s' % (p, ex))
+
+ if not options.cipd_server:
+ parser.error('--cipd-package requires non-empty --cipd-server')
+
+ if not options.cipd_client_package:
+ parser.error('--cipd-package requires non-empty --cipd-client-package')
+ try:
+ parse_package(options.cipd_client_package)
+ except ValueError as ex:
+ parser.error(
+ 'Invalid cipd client package %r: %s' %
+ (options.cipd_client_package, ex))
+
+
+class CipdClient(object):
+ """Installs packages."""
+
+ def __init__(self, binary_path, service_url=None):
+ """Initializes CipdClient.
+
+ Args:
+ binary_path (str): path to the CIPD client binary.
+ service_url (str): if not None, URL of the CIPD backend that overrides
+ the default one.
+ """
+ self.binary_path = binary_path
+ self.service_url = service_url
+
+ def ensure(self, site_root, packages, cache_dir=None, timeout=None):
+ """Ensures that packages installed in |site_root| equals |packages| set.
+
+ Blocking call.
+
+ Args:
+ site_root (str): where to install packages.
+ packages (str): list of packages to install, parsable by parse_pacakge().
+ cache_dir (str): if set, cache dir for cipd binary own cache.
+ Typically contains packages and tags.
+ timeout (int): if not None, timeout in seconds for this function to run.
+
+ Raises:
+ Error if could not install packages or timed out.
+ """
+ timeouter = tools.Timeouter(timeout)
+ logging.info('Installing packages %r into %s', packages, site_root)
+ with tempfile.NamedTemporaryFile(prefix='cipd-ensure-list-') as list_file:
Vadim Sh. 2016/06/06 21:32:13 keep it under cache_dir or site_root, not in globa
nodir 2016/06/07 18:46:35 Done.
+ for p in packages:
+ pkg, version = parse_package(p)
+ pkg = render_package_name_template(pkg)
+ list_file.write('%s %s\n' % (pkg, version))
+ list_file.flush()
Vadim Sh. 2016/06/06 21:32:13 there's а chance this won't work on Windows due to
M-A Ruel 2016/06/06 23:34:50 Vadim is right, this is broken on Windows.
nodir 2016/06/07 18:46:35 Done.
+
+ cmd = [
+ self.binary_path, 'ensure',
+ '-root', site_root,
+ '-list', list_file.name,
+ '-verbose', # this is safe because cipd-ensure does not print a lot
+ ]
+ if cache_dir:
+ cmd += ['-cache-dir', cache_dir]
+ if self.service_url:
+ cmd += ['-service-url', self.service_url]
+
+ logging.debug('Running %r', cmd)
+ process = subprocess42.Popen(
+ cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE)
+ output = []
+ for pipe_name, line in process.yield_any_line(timeout=timeouter.left):
+ if pipe_name is None:
M-A Ruel 2016/06/06 23:34:51 It could also mean that one pipe closed, not neces
nodir 2016/06/07 18:46:34 Done
+ raise Error(
+ 'Could not install packages; took more than %d seconds' % timeout)
Vadim Sh. 2016/06/06 21:32:13 We should probably add this to the cipd client too
nodir 2016/06/07 18:46:35 I started doing that, but it turned out much more
+ output.append(line)
+ if pipe_name == 'stderr':
+ logging.debug('cipd client: %s', line)
+ else:
+ logging.info('cipd client: %s', line)
+
+ exit_code = process.wait(timeout=timeouter.left())
+ if exit_code != 0:
+ raise Error(
+ 'Could not install packages; exit code %d\noutput:%s' % (
+ exit_code, '\n'.join(output)))
+
+
+def get_platform():
+ """Returns ${platform} parameter value.
+
+ Borrowed from
+ https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
+ """
+ # linux, mac or windows.
+ platform_variant = {
+ 'darwin': 'mac',
+ 'linux2': 'linux',
+ 'win32': 'windows',
+ }.get(sys.platform)
+ if not platform_variant:
+ raise Error('Unknown OS: %s' % sys.platform)
+
+ # amd64, 386, etc.
+ platform_arch = {
+ 'amd64': 'amd64',
+ 'i386': '386',
+ 'i686': '386',
+ 'x86': '386',
+ 'x86_64': 'amd64',
+ }.get(platform.machine().lower())
M-A Ruel 2016/06/06 23:34:50 https://github.com/luci/luci-py/blob/master/appeng
nodir 2016/06/07 18:46:34 that function cannot be imported here because this
+ if not platform_arch:
+ raise Error('Unknown machine arch: %s' % platform.machine())
+ return '%s-%s' % (platform_variant, platform_arch)
+
+
+def get_os_ver():
+ """Returns ${os_ver} parameter value.
+
+ Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
+
+ Borrowed from
+ https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
+ """
+ if sys.platform == 'darwin':
+ # platform.mac_ver()[0] is '10.9.5'.
+ dist = platform.mac_ver()[0].split('.')
+ return 'mac%s_%s' % (dist[0], dist[1])
+
+ if sys.platform == 'linux2':
+ # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
+ dist = platform.linux_distribution()
+ return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
+
+ if sys.platform == 'win32':
+ # platform.version() is '6.1.7601'.
+ dist = platform.version().split('.')
+ return 'win%s_%s' % (dist[0], dist[1])
+ raise Error('Unknown OS: %s' % sys.platform)
+
+
+def render_package_name_template(template):
+ """Expands template variables in a CIPD package name template."""
+ result = template
M-A Ruel 2016/06/06 23:34:50 this line is not useful.
nodir 2016/06/07 18:46:34 Done.
+ result = result.replace('${platform}', get_platform())
+ result = result.replace('${os_ver}', get_os_ver())
+ return result
M-A Ruel 2016/06/06 23:34:50 this is not needed either
nodir 2016/06/07 18:46:35 Done
+
+
+def parse_package(package):
+ """Parses a package in --cipd-package format.
+
+ Returns:
+ (package_name_template, version) tuple.
+
+ Raises:
+ ValueError if package name or version is not specified.
+ """
+ if not package:
+ raise ValueError('package is not specified')
+ parts = package.split(':', 1)
+ if len(parts) != 2:
+ raise ValueError('version is not specified')
+ return tuple(parts)
+
+
+def _check_response(res, fmt, *args):
+ """Raises Error if response is bad."""
+ if res and res.get('status') == 'SUCCESS':
M-A Ruel 2016/06/06 23:34:50 I'd replace the whole function with: if not res:
nodir 2016/06/07 18:46:35 Done.
+ return
+
+ if not res:
+ reason = 'no response'
+ else:
+ reason = res.get('error_message') or 'status is %s' % res.get('status')
+ raise Error(
+ '%s: %s' % (fmt & args, reason))
+
+
+def resolve_version(cipd_server, package_name, version, timeout=None):
+ """Resolves a package instance version (e.g. a tag) to an instance id."""
+ url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
+ cipd_server,
+ urllib.urlencode({
+ 'package_name': package_name,
+ 'version': version,
+ }))
+ res = net.url_read_json(url, timeout=timeout)
+ _check_response(res, 'Could not resolve version %s:%s', package_name, version)
+ instance_id = res.get('instance_id')
+ if not instance_id:
+ raise Error('Invalid resolveVersion response: no instance id')
+ return instance_id
+
+
+def fetch_client_binary(cipd_server, package_name, instance_id, timeout=None):
+ """Returns a net.HttpResponse with CIPD client binary contents.
+
+ Raises:
+ Error if cannot fetch the client.
+ """
+ # Fetch the URL of the binary from CIPD backend.
+ package_name = render_package_name_template(package_name)
+ url = '%s/_ah/api/repo/v1/client?%s' % (cipd_server, urllib.urlencode({
+ 'package_name': package_name,
+ 'instance_id': instance_id,
+ }))
+ res = net.url_read_json(url, timeout=timeout)
+ _check_response(
+ res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
+ fetch_url = res.get('client_binary', {}).get('fetch_url')
+ if not fetch_url:
+ raise Error('Invalid fetchClientBinary response: no fetch_url')
+ logging.debug('CIPD client binary fetch URL: %s', fetch_url)
+
+ # Now fetch the actual binary from Google Storage.
+ res = net.url_open(fetch_url, stream=False)
M-A Ruel 2016/06/06 23:34:50 that assumes the client binary fits in memory, we'
nodir 2016/06/07 18:46:34 I reworked fetch_client_binary to (url_open() | di
+ if res is None:
+ raise Error(
+ 'Could not fetch CIPD client %s:%s: no response' %
+ (package_name, instance_id))
+ return res
+
+
+def get_client(
+ service_url, package_name, version, client_cache, version_cache=None,
+ timeout=None):
+ """Creates a CipdClient. A blocking call.
+
+ The returned client points to a file in client_cache, thus client_cache
+ should not be mutated while CipdClient is used, otherwise the binary may be
+ deleted.
+
+ Args:
+ service_url (str): URL of the CIPD backend.
+ package_name (str): package name template of the CIPD client.
+ version (str): version of CIPD client package.
+ client_cache (isolateserver.DiskCache): cache for client binaries.
+ version_cache (isolateserver.LocalCache): if not None, cache for
+ {client version -> instance id} mapping.
+ timeout (int): if not None, timeout in seconds for this function.
+
+ Returns:
+ CipdClient.
+
+ Raises:
+ Error if CIPD client version cannot be resolved or client cannot be fetched.
+ """
+ timeouter = tools.Timeouter(timeout)
+ assert isinstance(client_cache, isolateserver.DiskCache)
+
+ package_name = render_package_name_template(package_name)
+
+ # Resolve version to instance id.
+ # Is it an instance id already? They look like HEX SHA1.
+ if isolated_format.is_valid_hash(version, hashlib.sha1):
+ instance_id = version
+ else:
+ instance_id = None
+ version_digest = None
+ if version_cache:
+ # Convert |version| to a string that may be used as a filename in disk
+ # cache by hashing it.
+ sha1 = hashlib.sha1()
+ sha1.update(version)
+ version_digest = sha1.hexdigest()
M-A Ruel 2016/06/06 23:34:50 version_digest = hashlib.sha1(version).hexdigest()
nodir 2016/06/07 18:46:35 Done.
+ try:
+ instance_id = version_cache.read(version_digest)
+ except isolateserver.CacheMiss:
+ pass
+ if not instance_id:
+ instance_id = resolve_version(
+ service_url, package_name, version, timeout=timeouter.left())
+ if version_cache:
+ version_cache.write(version_digest, instance_id)
+
+ # Get the path to the client binary inside client cache.
+ try:
+ binary_path = client_cache.item_path(instance_id)
+ except isolateserver.CacheMiss:
+ logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
+ res = fetch_client_binary(
+ service_url, package_name, instance_id, timeout=timeouter.left())
+ client_cache.write(instance_id, res.iter_content(64 * 1024))
M-A Ruel 2016/06/06 23:34:50 iter_content() doesn't matter here since stream=Fa
nodir 2016/06/07 18:46:34 changed to streaming
+ binary_path = client_cache.item_path(instance_id)
+ fs.chmod(binary_path, 0711) # -rwx--x--x
+ logging.info('Fetched CIPD client into %s', binary_path)
+ return CipdClient(binary_path)

Powered by Google App Engine
This is Rietveld 408576698