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

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: addressed comments 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 hashlib
10 import logging
11 import optparse
12 import os
13 import platform
14 import sys
15 import tempfile
16 import time
17 import urllib
18
19 from utils import file_path
20 from utils import fs
21 from utils import net
22 from utils import subprocess42
23 from utils import tools
24 import isolated_format
25 import isolateserver
26
27
28 # .exe on Windows.
29 EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else ''
30
31
32 class Error(Exception):
33 """Raised on CIPD errors."""
34
35
36 def add_cipd_options(parser):
37 group = optparse.OptionGroup(parser, 'CIPD')
38 group.add_option(
39 '--cipd-server',
40 help='URL of the CIPD server. Only relevant with --cipd-package. '
41 'Default: "%default"',
42 default='https://chrome-infra-packages.appspot.com')
M-A Ruel 2016/06/07 20:43:39 Please remove. We do not reference the chromium in
nodir 2016/06/07 21:51:37 Done.
43 group.add_option(
44 '--cipd-client-package',
45 help='Package of CIPD client. See --cipd-package for format. '
46 'Only relevant with --cipd-package. '
47 'Default: "%default"',
48 default='infra/tools/cipd/${platform}:latest')
49 group.add_option(
50 '--cipd-package',
51 help='CIPD package to install. '
52 'Format: "<package name template>:<version>". '
53 'Package name template is a CIPD package name with optional '
54 '${platform} and/or ${os_ver} parameters. '
55 '${platform} will be expanded to "<os>-<architecture>" and '
56 '${os_ver} will be expanded to OS version name. '
57 'This option can be specified more than once.',
58 action='append')
59 group.add_option(
60 '--cipd-cache',
61 help='CIPD cache directory, separate from isolate cache. '
62 'Only relevant with --cipd-package. '
63 'Default: "%default".')
M-A Ruel 2016/06/07 20:43:39 there's no defaul?
nodir 2016/06/07 21:51:37 https://cs.chromium.org/chromium/infra/luci/client
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
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
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 platform_arch = {
193 'amd64': 'amd64',
194 'i386': '386',
195 'i686': '386',
196 'x86': '386',
197 'x86_64': 'amd64',
198 }.get(platform.machine().lower())
199 if not platform_arch:
200 platform_arch = 'amd64' if sys.maxsize > 2**32 else '386'
M-A Ruel 2016/06/07 20:43:39 that's still highly incorrect on arm. amd64 and 38
Vadim Sh. 2016/06/07 20:59:42 That's what Golang is uses :) I adopted it for cip
nodir 2016/06/07 21:51:37 Done. Adapted from https://cs.chromium.org/chromi
201 return '%s-%s' % (platform_variant, platform_arch)
202
203
204 def get_os_ver():
M-A Ruel 2016/06/07 20:43:39 I wonder what's the value for Windows and OSX, esp
nodir 2016/06/07 21:51:37 See examples below
205 """Returns ${os_ver} parameter value.
206
207 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
208
209 Borrowed from
210 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
211 """
212 if sys.platform == 'darwin':
213 # platform.mac_ver()[0] is '10.9.5'.
214 dist = platform.mac_ver()[0].split('.')
215 return 'mac%s_%s' % (dist[0], dist[1])
216
217 if sys.platform == 'linux2':
218 # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
219 dist = platform.linux_distribution()
220 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
221
222 if sys.platform == 'win32':
223 # platform.version() is '6.1.7601'.
224 dist = platform.version().split('.')
225 return 'win%s_%s' % (dist[0], dist[1])
226 raise Error('Unknown OS: %s' % sys.platform)
227
228
229 def render_package_name_template(template):
230 """Expands template variables in a CIPD package name template."""
231 return (template
232 .replace('${platform}', get_platform())
233 .replace('${os_ver}', get_os_ver()))
234
235
236 def parse_package(package):
237 """Parses a package in --cipd-package format.
238
239 Returns:
240 (package_name_template, version) tuple.
241
242 Raises:
243 ValueError if package name or version is not specified.
244 """
245 if not package:
246 raise ValueError('package is not specified')
247 parts = package.split(':', 1)
248 if len(parts) != 2:
249 raise ValueError('version is not specified')
250 return tuple(parts)
251
252
253 def _check_response(res, fmt, *args):
254 """Raises Error if response is bad."""
255 if not res:
256 raise Error('%s: no response' % (fmt % args))
257
258 if res.get('status') != 'SUCCESS':
259 raise Error('%s: %s' % (
260 fmt % args,
261 res.get('error_message') or 'status is %s' % res.get('status')))
262
263
264 def resolve_version(cipd_server, package_name, version, timeout=None):
265 """Resolves a package instance version (e.g. a tag) to an instance id."""
266 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
267 cipd_server,
268 urllib.urlencode({
269 'package_name': package_name,
270 'version': version,
271 }))
272 res = net.url_read_json(url, timeout=timeout)
273 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
274 instance_id = res.get('instance_id')
275 if not instance_id:
276 raise Error('Invalid resolveVersion response: no instance id')
277 return instance_id
278
279
280 def get_client_fetch_url(service_url, package_name, instance_id, timeout=None):
281 """Returns a fetch URL of CIPD client binary contents.
282
283 Raises:
284 Error if cannot retrieve fetch URL.
285 """
286 # Fetch the URL of the binary from CIPD backend.
287 package_name = render_package_name_template(package_name)
288 url = '%s/_ah/api/repo/v1/client?%s' % (service_url, urllib.urlencode({
289 'package_name': package_name,
290 'instance_id': instance_id,
291 }))
292 res = net.url_read_json(url, timeout=timeout)
293 _check_response(
294 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
295 fetch_url = res.get('client_binary', {}).get('fetch_url')
296 if not fetch_url:
297 raise Error('Invalid fetchClientBinary response: no fetch_url')
298 return fetch_url
299
300
301 def _fetch_cipd_client(disk_cache, instance_id, fetch_url, timeoutfn):
302 """Fetches cipd binary to |disk_cache|.
303
304 Retries requests with exponential back-off.
305
306 Returns:
307 Path to the fetched file inside disk_cache.
308
309 Raises:
310 Error if could not fetch content.
311 """
312 sleep_time = 1
313 for attempt in xrange(5):
314 if attempt > 0:
315 if timeoutfn() is not None and timeoutfn() < sleep_time:
316 raise Error('Could not fetch CIPD client: timeout')
317 logging.warning('Will retry to fetch CIPD client in %ds', sleep_time)
318 time.sleep(sleep_time)
319 sleep_time *= 2
320
321 try:
322 res = net.url_open(fetch_url, timeout=timeoutfn())
323 if res:
324 disk_cache.write(instance_id, res.iter_content(64 * 1024))
325 return disk_cache.item_path(instance_id)
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 def get_client(
336 service_url, package_name, version, cache_dir, timeout=None):
337 """Creates a CipdClient. A blocking call.
338
339 Args:
340 service_url (str): URL of the CIPD backend.
341 package_name (str): package name template of the CIPD client.
342 version (str): version of CIPD client package.
343 cache_dir: directory to store instance cache, version cache
344 and a hardlink to the client binary.
345 timeout (int): if not None, timeout in seconds for this function.
346
347 Returns:
348 CipdClient.
349
350 Raises:
351 Error if CIPD client version cannot be resolved or client cannot be fetched.
352 """
353 timeoutfn = tools.sliding_timeout(timeout)
354
355 package_name = render_package_name_template(package_name)
356
357 # Resolve version to instance id.
358 # Is it an instance id already? They look like HEX SHA1.
359 if isolated_format.is_valid_hash(version, hashlib.sha1):
360 instance_id = version
361 else:
362 # version_cache is {version_digest -> instance id} mapping.
363 # It does not take a lot of disk space.
364 version_cache = isolateserver.DiskCache(
365 unicode(os.path.join(cache_dir, 'versions')),
366 isolateserver.CachePolicies(0, 0, 300),
367 hashlib.sha1)
368
369 # Convert |version| to a string that may be used as a filename in disk cache
370 # by hashing it.
371 version_digest = hashlib.sha1(version).hexdigest()
372 try:
373 instance_id = version_cache.read(version_digest)
374 except isolateserver.CacheMiss:
375 instance_id = resolve_version(
376 service_url, package_name, version, timeout=timeoutfn())
377 version_cache.write(version_digest, instance_id)
378
379 # instance_cache is {instance_id -> client binary} mapping.
380 # It is bounded by 5 client versions.
381 instance_cache = isolateserver.DiskCache(
M-A Ruel 2016/06/07 20:43:39 The instance should outlive the mapped files
nodir 2016/06/07 21:51:37 I don't understand this comment. what is the diffe
M-A Ruel 2016/06/08 20:37:39 instance = object. This means instance_cache shoul
nodir 2016/06/08 22:35:32 Made get_client return a context manager that yiel
382 unicode(os.path.join(cache_dir, 'clients')),
383 isolateserver.CachePolicies(0, 0, 5),
384 hashlib.sha1)
385 # Get the path to the client binary inside instance cache.
386 try:
387 cached_binary_path = instance_cache.item_path(instance_id)
388 except isolateserver.CacheMiss:
389 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
390 fetch_url = get_client_fetch_url(
391 service_url, package_name, instance_id, timeout=timeoutfn())
392 cached_binary_path = _fetch_cipd_client(
393 instance_cache, instance_id, fetch_url, timeoutfn)
394
395 # A single bot can run multiple swarming clients, but ATM they do not share
M-A Ruel 2016/06/07 20:43:39 s/single bot/single host/ s/clients/bots/ s/share
nodir 2016/06/07 21:51:37 Done.
396 # cache. Thus, it is safe to use the same name for the binary.
397 binary_path = unicode(os.path.join(cache_dir, 'cipd' + EXECUTABLE_SUFFIX))
398 if fs.isfile(binary_path):
399 fs.unlink(binary_path)
400 file_path.hardlink(cached_binary_path, binary_path)
M-A Ruel 2016/06/07 20:43:39 I'd prefer to use instance_cache.hardlink()
nodir 2016/06/07 21:51:37 yeah, that's better. I thought hardlink allows to
401 fs.chmod(binary_path, 0711) # -rwx--x--x
M-A Ruel 2016/06/07 20:43:39 Why write bit set?
nodir 2016/06/07 21:51:37 I assumed you cannot delete a file without w bit.
M-A Ruel 2016/06/08 20:37:39 In practice, that is not true on Windows; as remov
nodir 2016/06/08 22:35:32 I've sshed to a windows machine, created a file wi
402
403 return CipdClient(binary_path)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698