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

Side by Side 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: simplified closing of list file 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 unified diff | Download patch
OLDNEW
(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 try:
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 finally:
133 os.close(list_file_handle)
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 fs.remove(list_file_path)
172
173
174 def get_platform():
175 """Returns ${platform} parameter value.
176
177 Borrowed from
178 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
179 """
180 # linux, mac or windows.
181 platform_variant = {
182 'darwin': 'mac',
183 'linux2': 'linux',
184 'win32': 'windows',
185 }.get(sys.platform)
186 if not platform_variant:
187 raise Error('Unknown OS: %s' % sys.platform)
188
189 # amd64, 386, etc.
190 machine = platform.machine().lower()
191 platform_arch = {
192 'amd64': 'amd64',
193 'i386': '386',
194 'i686': '386',
195 'x86': '386',
196 'x86_64': 'amd64',
197 }.get(machine)
198 if not platform_arch:
199 if machine.startswith('arm'):
200 platform_arch = 'armv6l'
201 else:
202 platform_arch = 'amd64' if sys.maxsize > 2**32 else '386'
203 return '%s-%s' % (platform_variant, platform_arch)
204
205
206 def get_os_ver():
207 """Returns ${os_ver} parameter value.
208
209 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
210
211 Borrowed from
212 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
213 """
214 if sys.platform == 'darwin':
215 # platform.mac_ver()[0] is '10.9.5'.
216 dist = platform.mac_ver()[0].split('.')
217 return 'mac%s_%s' % (dist[0], dist[1])
218
219 if sys.platform == 'linux2':
220 # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
221 dist = platform.linux_distribution()
222 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
223
224 if sys.platform == 'win32':
225 # platform.version() is '6.1.7601'.
226 dist = platform.version().split('.')
227 return 'win%s_%s' % (dist[0], dist[1])
228 raise Error('Unknown OS: %s' % sys.platform)
229
230
231 def render_package_name_template(template):
232 """Expands template variables in a CIPD package name template."""
233 return (template
234 .replace('${platform}', get_platform())
235 .replace('${os_ver}', get_os_ver()))
236
237
238 def parse_package(package):
239 """Parses a package in --cipd-package format.
240
241 Returns:
242 (package_name_template, version) tuple.
243
244 Raises:
245 ValueError if package name or version is not specified.
246 """
247 if not package:
248 raise ValueError('package is not specified')
249 parts = package.split(':', 1)
250 if len(parts) != 2:
251 raise ValueError('version is not specified')
252 return tuple(parts)
253
254
255 def _check_response(res, fmt, *args):
256 """Raises Error if response is bad."""
257 if not res:
258 raise Error('%s: no response' % (fmt % args))
259
260 if res.get('status') != 'SUCCESS':
261 raise Error('%s: %s' % (
262 fmt % args,
263 res.get('error_message') or 'status is %s' % res.get('status')))
264
265
266 def resolve_version(cipd_server, package_name, version, timeout=None):
267 """Resolves a package instance version (e.g. a tag) to an instance id."""
268 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
269 cipd_server,
270 urllib.urlencode({
271 'package_name': package_name,
272 'version': version,
273 }))
274 res = net.url_read_json(url, timeout=timeout)
275 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
276 instance_id = res.get('instance_id')
277 if not instance_id:
278 raise Error('Invalid resolveVersion response: no instance id')
279 return instance_id
280
281
282 def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
283 """Returns a fetch URL of CIPD client binary contents.
284
285 Raises:
286 Error if cannot retrieve fetch URL.
287 """
288 # Fetch the URL of the binary from CIPD backend.
289 package_name = render_package_name_template(package_name)
290 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
291 'package_name': package_name,
292 'instance_id': instance_id,
293 }))
294 res = net.url_read_json(url, timeout=timeout)
295 _check_response(
296 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
297 fetch_url = res.get('client_binary', {}).get('fetch_url')
298 if not fetch_url:
299 raise Error('Invalid fetchClientBinary response: no fetch_url')
300 return fetch_url
301
302
303 def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
304 """Fetches cipd binary to |disk_cache|.
305
306 Retries requests with exponential back-off.
307
308 Raises:
309 Error if could not fetch content.
310 """
311 sleep_time = 1
312 for attempt in xrange(5):
313 if attempt > 0:
314 if timeoutfn() is not None and timeoutfn() < sleep_time:
315 raise Error('Could not fetch CIPD client: timeout')
316 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
317 time.sleep(sleep_time)
318 sleep_time *= 2
319
320 try:
321 res = net.url_open(fetch_url, timeout=timeoutfn())
322 if res:
323 disk_cache.write(instance_id, res.iter_content(64 * 1024))
324 except net.TimeoutError as ex:
325 raise Error('Could not fetch CIPD client: %s', ex)
326 except net.NetError as ex:
327 logging.warning(
328 'Could not fetch CIPD client on attempt #%d: %s', attempt + 1, ex)
329
330 raise Error('Could not fetch CIPD client after 5 retries')
331
332
333 @contextlib.contextmanager
334 def get_client(
335 service_url, package_name, version, cache_dir, timeout=None):
336 """Returns a context manager that yields a CipdClient. A blocking call.
337
338 Args:
339 service_url (str): URL of the CIPD backend.
340 package_name (str): package name template of the CIPD client.
341 version (str): version of CIPD client package.
342 cache_dir: directory to store instance cache, version cache
343 and a hardlink to the client binary.
344 timeout (int): if not None, timeout in seconds for this function.
345
346 Yields:
347 CipdClient.
348
349 Raises:
350 Error if CIPD client version cannot be resolved or client cannot be fetched.
351 """
352 timeoutfn = tools.sliding_timeout(timeout)
353
354 package_name = render_package_name_template(package_name)
355
356 # Resolve version to instance id.
357 # Is it an instance id already? They look like HEX SHA1.
358 if isolated_format.is_valid_hash(version, hashlib.sha1):
359 instance_id = version
360 else:
361 # version_cache is {version_digest -> instance id} mapping.
362 # It does not take a lot of disk space.
363 version_cache = isolateserver.DiskCache(
364 unicode(os.path.join(cache_dir, 'versions')),
365 isolateserver.CachePolicies(0, 0, 300),
366 hashlib.sha1)
367 with version_cache:
368 # Convert |version| to a string that may be used as a filename in disk
369 # cache by hashing it.
370 version_digest = hashlib.sha1(version).hexdigest()
371 try:
372 instance_id = version_cache.read(version_digest)
373 except isolateserver.CacheMiss:
374 instance_id = resolve_version(
375 service_url, package_name, version, timeout=timeoutfn())
376 version_cache.write(version_digest, instance_id)
377
378 # instance_cache is {instance_id -> client binary} mapping.
379 # It is bounded by 5 client versions.
380 instance_cache = isolateserver.DiskCache(
381 unicode(os.path.join(cache_dir, 'clients')),
382 isolateserver.CachePolicies(0, 0, 5),
383 hashlib.sha1)
384 with instance_cache:
385 if instance_id not in instance_cache:
386 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
387 fetch_url = get_client_fetch_url(
388 service_url, package_name, instance_id, timeout=timeoutfn())
389 _fetch_cipd_client(instance_cache, instance_id, fetch_url, timeoutfn)
390
391 # A single host can run multiple swarming bots, but ATM they do not share sa me
M-A Ruel 2016/06/09 18:31:11 80 cols
nodir 2016/06/09 21:36:11 Done.
392 # root bot directory. Thus, it is safe to use the same name for the binary.
393 binary_path = unicode(os.path.join(cache_dir, 'cipd' + EXECUTABLE_SUFFIX))
394 if fs.isfile(binary_path):
395 fs.unlink(binary_path)
396 instance_cache.hardlink(instance_id, binary_path, 0511) # -r-x--x--x
397
398 yield CipdClient(binary_path)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698