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

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: 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 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.
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 platform
13 import sys
14 import tempfile
15 import urllib
16
17 from utils import fs
18 from utils import net
19 from utils import subprocess42
20 from utils import tools
21 import isolated_format
22 import isolateserver
23
24
25 class Error(Exception):
26 """Raised on CIPD errors."""
27
28
29 def add_cipd_options(parser):
30 group = optparse.OptionGroup(parser, 'CIPD')
31 group.add_option(
32 '--cipd-server',
33 help=(
M-A Ruel 2016/06/06 23:34:50 () are not needed for str concatenation
nodir 2016/06/07 18:46:35 Done.
34 'URL of the CIPD server. Only relevant with --cipd-package. '
35 'Default: "%default"'),
36 default='https://chrome-infra-packages.appspot.com')
37 group.add_option(
38 '--cipd-client-package',
39 help=(
40 'Package of CIPD client. See --cipd-package for format. '
41 'Only relevant with --cipd-package. '
42 'Default: "%default"'),
43 default='infra/tools/cipd/${platform}:latest')
44 group.add_option(
45 '--cipd-package',
46 help=(
47 'CIPD package to install. '
48 'Format: "<package name template>:<version>". '
49 'Package name template is a CIPD package name with optional '
50 '${platform} and/or ${os_ver} parameters. '
51 '${platform} will be expanded to "<os>-<architecture>" and '
52 '${os_ver} will be expanded to OS version name. '
53 'This option can be specified more than once.'),
54 action='append')
55 group.add_option(
56 '--cipd-cache',
57 help=(
58 'CIPD cache directory, separate from isolate cache. '
59 'Only relevant with --cipd-package. '
60 'Default: "%default".'))
61 parser.add_option_group(group)
62
63
64 def validate_cipd_options(parser, options):
65 """Calls parser.error on first found error among cipd options."""
66 if not options.cipd_package:
67 return
68 for p in options.cipd_package:
69 try:
70 parse_package(p)
71 except ValueError as ex:
72 parser.error('Invalid cipd package %r: %s' % (p, ex))
73
74 if not options.cipd_server:
75 parser.error('--cipd-package requires non-empty --cipd-server')
76
77 if not options.cipd_client_package:
78 parser.error('--cipd-package requires non-empty --cipd-client-package')
79 try:
80 parse_package(options.cipd_client_package)
81 except ValueError as ex:
82 parser.error(
83 'Invalid cipd client package %r: %s' %
84 (options.cipd_client_package, ex))
85
86
87 class CipdClient(object):
88 """Installs packages."""
89
90 def __init__(self, binary_path, service_url=None):
91 """Initializes CipdClient.
92
93 Args:
94 binary_path (str): path to the CIPD client binary.
95 service_url (str): if not None, URL of the CIPD backend that overrides
96 the default one.
97 """
98 self.binary_path = binary_path
99 self.service_url = service_url
100
101 def ensure(self, site_root, packages, cache_dir=None, timeout=None):
102 """Ensures that packages installed in |site_root| equals |packages| set.
103
104 Blocking call.
105
106 Args:
107 site_root (str): where to install packages.
108 packages (str): list of packages to install, parsable by parse_pacakge().
109 cache_dir (str): if set, cache dir for cipd binary own cache.
110 Typically contains packages and tags.
111 timeout (int): if not None, timeout in seconds for this function to run.
112
113 Raises:
114 Error if could not install packages or timed out.
115 """
116 timeouter = tools.Timeouter(timeout)
117 logging.info('Installing packages %r into %s', packages, site_root)
118 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.
119 for p in packages:
120 pkg, version = parse_package(p)
121 pkg = render_package_name_template(pkg)
122 list_file.write('%s %s\n' % (pkg, version))
123 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.
124
125 cmd = [
126 self.binary_path, 'ensure',
127 '-root', site_root,
128 '-list', list_file.name,
129 '-verbose', # this is safe because cipd-ensure does not print a lot
130 ]
131 if cache_dir:
132 cmd += ['-cache-dir', cache_dir]
133 if self.service_url:
134 cmd += ['-service-url', self.service_url]
135
136 logging.debug('Running %r', cmd)
137 process = subprocess42.Popen(
138 cmd, stdout=subprocess42.PIPE, stderr=subprocess42.PIPE)
139 output = []
140 for pipe_name, line in process.yield_any_line(timeout=timeouter.left):
141 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
142 raise Error(
143 '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
144 output.append(line)
145 if pipe_name == 'stderr':
146 logging.debug('cipd client: %s', line)
147 else:
148 logging.info('cipd client: %s', line)
149
150 exit_code = process.wait(timeout=timeouter.left())
151 if exit_code != 0:
152 raise Error(
153 'Could not install packages; exit code %d\noutput:%s' % (
154 exit_code, '\n'.join(output)))
155
156
157 def get_platform():
158 """Returns ${platform} parameter value.
159
160 Borrowed from
161 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
162 """
163 # linux, mac or windows.
164 platform_variant = {
165 'darwin': 'mac',
166 'linux2': 'linux',
167 'win32': 'windows',
168 }.get(sys.platform)
169 if not platform_variant:
170 raise Error('Unknown OS: %s' % sys.platform)
171
172 # amd64, 386, etc.
173 platform_arch = {
174 'amd64': 'amd64',
175 'i386': '386',
176 'i686': '386',
177 'x86': '386',
178 'x86_64': 'amd64',
179 }.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
180 if not platform_arch:
181 raise Error('Unknown machine arch: %s' % platform.machine())
182 return '%s-%s' % (platform_variant, platform_arch)
183
184
185 def get_os_ver():
186 """Returns ${os_ver} parameter value.
187
188 Examples: 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
189
190 Borrowed from
191 https://chromium.googlesource.com/infra/infra/+/aaf9586/build/build.py#204
192 """
193 if sys.platform == 'darwin':
194 # platform.mac_ver()[0] is '10.9.5'.
195 dist = platform.mac_ver()[0].split('.')
196 return 'mac%s_%s' % (dist[0], dist[1])
197
198 if sys.platform == 'linux2':
199 # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
200 dist = platform.linux_distribution()
201 return '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
202
203 if sys.platform == 'win32':
204 # platform.version() is '6.1.7601'.
205 dist = platform.version().split('.')
206 return 'win%s_%s' % (dist[0], dist[1])
207 raise Error('Unknown OS: %s' % sys.platform)
208
209
210 def render_package_name_template(template):
211 """Expands template variables in a CIPD package name template."""
212 result = template
M-A Ruel 2016/06/06 23:34:50 this line is not useful.
nodir 2016/06/07 18:46:34 Done.
213 result = result.replace('${platform}', get_platform())
214 result = result.replace('${os_ver}', get_os_ver())
215 return result
M-A Ruel 2016/06/06 23:34:50 this is not needed either
nodir 2016/06/07 18:46:35 Done
216
217
218 def parse_package(package):
219 """Parses a package in --cipd-package format.
220
221 Returns:
222 (package_name_template, version) tuple.
223
224 Raises:
225 ValueError if package name or version is not specified.
226 """
227 if not package:
228 raise ValueError('package is not specified')
229 parts = package.split(':', 1)
230 if len(parts) != 2:
231 raise ValueError('version is not specified')
232 return tuple(parts)
233
234
235 def _check_response(res, fmt, *args):
236 """Raises Error if response is bad."""
237 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.
238 return
239
240 if not res:
241 reason = 'no response'
242 else:
243 reason = res.get('error_message') or 'status is %s' % res.get('status')
244 raise Error(
245 '%s: %s' % (fmt & args, reason))
246
247
248 def resolve_version(cipd_server, package_name, version, timeout=None):
249 """Resolves a package instance version (e.g. a tag) to an instance id."""
250 url = '%s/_ah/api/repo/v1/instance/resolve?%s' % (
251 cipd_server,
252 urllib.urlencode({
253 'package_name': package_name,
254 'version': version,
255 }))
256 res = net.url_read_json(url, timeout=timeout)
257 _check_response(res, 'Could not resolve version %s:%s', package_name, version)
258 instance_id = res.get('instance_id')
259 if not instance_id:
260 raise Error('Invalid resolveVersion response: no instance id')
261 return instance_id
262
263
264 def fetch_client_binary(cipd_server, package_name, instance_id, timeout=None):
265 """Returns a net.HttpResponse with CIPD client binary contents.
266
267 Raises:
268 Error if cannot fetch the client.
269 """
270 # Fetch the URL of the binary from CIPD backend.
271 package_name = render_package_name_template(package_name)
272 url = '%s/_ah/api/repo/v1/client?%s' % (cipd_server, urllib.urlencode({
273 'package_name': package_name,
274 'instance_id': instance_id,
275 }))
276 res = net.url_read_json(url, timeout=timeout)
277 _check_response(
278 res, 'Could not fetch CIPD client %s:%s',package_name, instance_id)
279 fetch_url = res.get('client_binary', {}).get('fetch_url')
280 if not fetch_url:
281 raise Error('Invalid fetchClientBinary response: no fetch_url')
282 logging.debug('CIPD client binary fetch URL: %s', fetch_url)
283
284 # Now fetch the actual binary from Google Storage.
285 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
286 if res is None:
287 raise Error(
288 'Could not fetch CIPD client %s:%s: no response' %
289 (package_name, instance_id))
290 return res
291
292
293 def get_client(
294 service_url, package_name, version, client_cache, version_cache=None,
295 timeout=None):
296 """Creates a CipdClient. A blocking call.
297
298 The returned client points to a file in client_cache, thus client_cache
299 should not be mutated while CipdClient is used, otherwise the binary may be
300 deleted.
301
302 Args:
303 service_url (str): URL of the CIPD backend.
304 package_name (str): package name template of the CIPD client.
305 version (str): version of CIPD client package.
306 client_cache (isolateserver.DiskCache): cache for client binaries.
307 version_cache (isolateserver.LocalCache): if not None, cache for
308 {client version -> instance id} mapping.
309 timeout (int): if not None, timeout in seconds for this function.
310
311 Returns:
312 CipdClient.
313
314 Raises:
315 Error if CIPD client version cannot be resolved or client cannot be fetched.
316 """
317 timeouter = tools.Timeouter(timeout)
318 assert isinstance(client_cache, isolateserver.DiskCache)
319
320 package_name = render_package_name_template(package_name)
321
322 # Resolve version to instance id.
323 # Is it an instance id already? They look like HEX SHA1.
324 if isolated_format.is_valid_hash(version, hashlib.sha1):
325 instance_id = version
326 else:
327 instance_id = None
328 version_digest = None
329 if version_cache:
330 # Convert |version| to a string that may be used as a filename in disk
331 # cache by hashing it.
332 sha1 = hashlib.sha1()
333 sha1.update(version)
334 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.
335 try:
336 instance_id = version_cache.read(version_digest)
337 except isolateserver.CacheMiss:
338 pass
339 if not instance_id:
340 instance_id = resolve_version(
341 service_url, package_name, version, timeout=timeouter.left())
342 if version_cache:
343 version_cache.write(version_digest, instance_id)
344
345 # Get the path to the client binary inside client cache.
346 try:
347 binary_path = client_cache.item_path(instance_id)
348 except isolateserver.CacheMiss:
349 logging.info('Fetching CIPD client %s:%s', package_name, instance_id)
350 res = fetch_client_binary(
351 service_url, package_name, instance_id, timeout=timeouter.left())
352 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
353 binary_path = client_cache.item_path(instance_id)
354 fs.chmod(binary_path, 0711) # -rwx--x--x
355 logging.info('Fetched CIPD client into %s', binary_path)
356 return CipdClient(binary_path)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698