blob: 2cd3ff2a69109a0e5f2301cc90bf1992e8cfa42e [file] [log] [blame]
Keun Soo Yimb293fdb2016-09-21 16:03:44 -07001#!/usr/bin/env python
2#
3# Copyright 2016 - The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
Fang Deng26e4dc12018-03-04 19:01:59 -080016"""Common Utilities."""
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070017
18import base64
19import binascii
20import errno
Fang Deng69498c32017-03-02 14:29:30 -080021import getpass
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070022import logging
23import os
24import shutil
25import struct
Fang Deng69498c32017-03-02 14:29:30 -080026import subprocess
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070027import sys
28import tarfile
29import tempfile
30import time
31import uuid
32
33from acloud.public import errors
34
35logger = logging.getLogger(__name__)
36
Fang Deng69498c32017-03-02 14:29:30 -080037SSH_KEYGEN_CMD = ["ssh-keygen", "-t", "rsa", "-b", "4096"]
cylan4f73c1f2018-07-19 16:40:31 +080038SSH_KEYGEN_PUB_CMD = ["ssh-keygen", "-y"]
Kevin Chengd25feee2018-05-24 10:15:20 -070039DEFAULT_RETRY_BACKOFF_FACTOR = 1
40DEFAULT_SLEEP_MULTIPLIER = 0
Fang Deng69498c32017-03-02 14:29:30 -080041
42
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070043class TempDir(object):
Fang Deng26e4dc12018-03-04 19:01:59 -080044 """A context manager that ceates a temporary directory.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070045
Fang Deng26e4dc12018-03-04 19:01:59 -080046 Attributes:
47 path: The path of the temporary directory.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070048 """
49
Fang Deng26e4dc12018-03-04 19:01:59 -080050 def __init__(self):
51 self.path = tempfile.mkdtemp()
52 os.chmod(self.path, 0o700)
53 logger.debug("Created temporary dir %s", self.path)
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070054
55 def __enter__(self):
Fang Deng26e4dc12018-03-04 19:01:59 -080056 """Enter."""
57 return self.path
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070058
Fang Deng26e4dc12018-03-04 19:01:59 -080059 def __exit__(self, exc_type, exc_value, traceback):
60 """Exit.
61
62 Args:
63 exc_type: Exception type raised within the context manager.
64 None if no execption is raised.
65 exc_value: Exception instance raised within the context manager.
66 None if no execption is raised.
67 traceback: Traceback for exeception that is raised within
68 the context manager.
69 None if no execption is raised.
70 Raises:
71 EnvironmentError or OSError when failed to delete temp directory.
72 """
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070073 try:
Fang Deng26e4dc12018-03-04 19:01:59 -080074 if self.path:
75 shutil.rmtree(self.path)
76 logger.debug("Deleted temporary dir %s", self.path)
77 except EnvironmentError as e:
78 # Ignore error if there is no exception raised
79 # within the with-clause and the EnvironementError is
80 # about problem that directory or file does not exist.
81 if not exc_type and e.errno != errno.ENOENT:
82 raise
83 except Exception as e: # pylint: disable=W0703
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070084 if exc_type:
Fang Deng26e4dc12018-03-04 19:01:59 -080085 logger.error(
cylan0d77ae12018-05-18 08:36:48 +000086 "Encountered error while deleting %s: %s",
87 self.path,
88 str(e),
89 exc_info=True)
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070090 else:
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070091 raise
92
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070093
cylan0d77ae12018-05-18 08:36:48 +000094def RetryOnException(retry_checker,
95 max_retries,
96 sleep_multiplier=0,
Fang Dengf24be082018-02-10 10:09:55 -080097 retry_backoff_factor=1):
cylan0d77ae12018-05-18 08:36:48 +000098 """Decorater which retries the function call if |retry_checker| returns true.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -070099
cylan0d77ae12018-05-18 08:36:48 +0000100 Args:
101 retry_checker: A callback function which should take an exception instance
102 and return True if functor(*args, **kwargs) should be retried
103 when such exception is raised, and return False if it should
104 not be retried.
105 max_retries: Maximum number of retries allowed.
106 sleep_multiplier: Will sleep sleep_multiplier * attempt_count seconds if
107 retry_backoff_factor is 1. Will sleep
108 sleep_multiplier * (
109 retry_backoff_factor ** (attempt_count - 1))
110 if retry_backoff_factor != 1.
111 retry_backoff_factor: See explanation of sleep_multiplier.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700112
cylan0d77ae12018-05-18 08:36:48 +0000113 Returns:
114 The function wrapper.
115 """
116
117 def _Wrapper(func):
118 def _FunctionWrapper(*args, **kwargs):
119 return Retry(retry_checker, max_retries, func, sleep_multiplier,
120 retry_backoff_factor, *args, **kwargs)
121
122 return _FunctionWrapper
123
124 return _Wrapper
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700125
126
cylan4f73c1f2018-07-19 16:40:31 +0800127def Retry(retry_checker, max_retries, functor, sleep_multiplier,
128 retry_backoff_factor, *args, **kwargs):
cylan0d77ae12018-05-18 08:36:48 +0000129 """Conditionally retry a function.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700130
cylan0d77ae12018-05-18 08:36:48 +0000131 Args:
132 retry_checker: A callback function which should take an exception instance
133 and return True if functor(*args, **kwargs) should be retried
134 when such exception is raised, and return False if it should
135 not be retried.
136 max_retries: Maximum number of retries allowed.
137 functor: The function to call, will call functor(*args, **kwargs).
138 sleep_multiplier: Will sleep sleep_multiplier * attempt_count seconds if
139 retry_backoff_factor is 1. Will sleep
140 sleep_multiplier * (
141 retry_backoff_factor ** (attempt_count - 1))
142 if retry_backoff_factor != 1.
143 retry_backoff_factor: See explanation of sleep_multiplier.
144 *args: Arguments to pass to the functor.
145 **kwargs: Key-val based arguments to pass to the functor.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700146
cylan0d77ae12018-05-18 08:36:48 +0000147 Returns:
148 The return value of the functor.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700149
cylan0d77ae12018-05-18 08:36:48 +0000150 Raises:
151 Exception: The exception that functor(*args, **kwargs) throws.
152 """
153 attempt_count = 0
154 while attempt_count <= max_retries:
155 try:
156 attempt_count += 1
157 return_value = functor(*args, **kwargs)
158 return return_value
159 except Exception as e: # pylint: disable=W0703
160 if retry_checker(e) and attempt_count <= max_retries:
161 if retry_backoff_factor != 1:
162 sleep = sleep_multiplier * (retry_backoff_factor**
163 (attempt_count - 1))
164 else:
165 sleep = sleep_multiplier * attempt_count
Kevin Chengd25feee2018-05-24 10:15:20 -0700166 time.sleep(sleep)
cylan0d77ae12018-05-18 08:36:48 +0000167 else:
168 raise
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700169
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700170
Fang Dengf24be082018-02-10 10:09:55 -0800171def RetryExceptionType(exception_types, max_retries, functor, *args, **kwargs):
cylan0d77ae12018-05-18 08:36:48 +0000172 """Retry exception if it is one of the given types.
Fang Dengf24be082018-02-10 10:09:55 -0800173
cylan0d77ae12018-05-18 08:36:48 +0000174 Args:
175 exception_types: A tuple of exception types, e.g. (ValueError, KeyError)
176 max_retries: Max number of retries allowed.
177 functor: The function to call. Will be retried if exception is raised and
178 the exception is one of the exception_types.
179 *args: Arguments to pass to Retry function.
180 **kwargs: Key-val based arguments to pass to Retry functions.
Fang Dengf24be082018-02-10 10:09:55 -0800181
cylan0d77ae12018-05-18 08:36:48 +0000182 Returns:
183 The value returned by calling functor.
184 """
185 return Retry(lambda e: isinstance(e, exception_types), max_retries,
186 functor, *args, **kwargs)
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700187
188
189def PollAndWait(func, expected_return, timeout_exception, timeout_secs,
190 sleep_interval_secs, *args, **kwargs):
191 """Call a function until the function returns expected value or times out.
192
193 Args:
194 func: Function to call.
195 expected_return: The expected return value.
196 timeout_exception: Exception to raise when it hits timeout.
197 timeout_secs: Timeout seconds.
198 If 0 or less than zero, the function will run once and
199 we will not wait on it.
200 sleep_interval_secs: Time to sleep between two attemps.
201 *args: list of args to pass to func.
202 **kwargs: dictionary of keyword based args to pass to func.
203
204 Raises:
205 timeout_exception: if the run of function times out.
206 """
207 # TODO(fdeng): Currently this method does not kill
208 # |func|, if |func| takes longer than |timeout_secs|.
209 # We can use a more robust version from chromite.
210 start = time.time()
211 while True:
212 return_value = func(*args, **kwargs)
213 if return_value == expected_return:
214 return
215 elif time.time() - start > timeout_secs:
216 raise timeout_exception
217 else:
218 if sleep_interval_secs > 0:
219 time.sleep(sleep_interval_secs)
220
221
222def GenerateUniqueName(prefix=None, suffix=None):
223 """Generate a random unque name using uuid4.
224
225 Args:
226 prefix: String, desired prefix to prepend to the generated name.
227 suffix: String, desired suffix to append to the generated name.
228
229 Returns:
230 String, a random name.
231 """
232 name = uuid.uuid4().hex
233 if prefix:
234 name = "-".join([prefix, name])
235 if suffix:
236 name = "-".join([name, suffix])
237 return name
238
239
240def MakeTarFile(src_dict, dest):
241 """Archive files in tar.gz format to a file named as |dest|.
242
243 Args:
244 src_dict: A dictionary that maps a path to be archived
245 to the corresponding name that appears in the archive.
246 dest: String, path to output file, e.g. /tmp/myfile.tar.gz
247 """
248 logger.info("Compressing %s into %s.", src_dict.keys(), dest)
249 with tarfile.open(dest, "w:gz") as tar:
250 for src, arcname in src_dict.iteritems():
251 tar.add(src, arcname=arcname)
252
253
Fang Deng69498c32017-03-02 14:29:30 -0800254def CreateSshKeyPairIfNotExist(private_key_path, public_key_path):
255 """Create the ssh key pair if they don't exist.
256
cylan4f73c1f2018-07-19 16:40:31 +0800257 Case1. If the private key doesn't exist, we will create both the public key
258 and the private key.
259 Case2. If the private key exists but public key doesn't, we will create the
260 public key by using the private key.
261 Case3. If the public key exists but the private key doesn't, we will create
262 a new private key and overwrite the public key.
Fang Deng69498c32017-03-02 14:29:30 -0800263
264 Args:
265 private_key_path: Path to the private key file.
266 e.g. ~/.ssh/acloud_rsa
267 public_key_path: Path to the public key file.
268 e.g. ~/.ssh/acloud_rsa.pub
cylan4f73c1f2018-07-19 16:40:31 +0800269
Fang Deng69498c32017-03-02 14:29:30 -0800270 Raises:
271 error.DriverError: If failed to create the key pair.
272 """
273 public_key_path = os.path.expanduser(public_key_path)
274 private_key_path = os.path.expanduser(private_key_path)
cylan4f73c1f2018-07-19 16:40:31 +0800275 public_key_exist = os.path.exists(public_key_path)
276 private_key_exist = os.path.exists(private_key_path)
277 if public_key_exist and private_key_exist:
cylan0d77ae12018-05-18 08:36:48 +0000278 logger.debug(
cylan4f73c1f2018-07-19 16:40:31 +0800279 "The ssh private key (%s) and public key (%s) already exist,"
cylan0d77ae12018-05-18 08:36:48 +0000280 "will not automatically create the key pairs.", private_key_path,
281 public_key_path)
Fang Deng69498c32017-03-02 14:29:30 -0800282 return
cylan4f73c1f2018-07-19 16:40:31 +0800283 key_folder = os.path.dirname(private_key_path)
284 if not os.path.exists(key_folder):
285 os.makedirs(key_folder)
Fang Deng69498c32017-03-02 14:29:30 -0800286 try:
cylan4f73c1f2018-07-19 16:40:31 +0800287 if private_key_exist:
288 cmd = SSH_KEYGEN_PUB_CMD + ["-f", private_key_path]
289 with open(public_key_path, 'w') as outfile:
290 stream_content = subprocess.check_output(cmd)
291 outfile.write(
292 stream_content.rstrip('\n') + " " + getpass.getuser())
293 logger.info(
294 "The ssh public key (%s) do not exist, "
295 "automatically creating public key, calling: %s",
296 public_key_path, " ".join(cmd))
297 else:
298 cmd = SSH_KEYGEN_CMD + [
299 "-C", getpass.getuser(), "-f", private_key_path
300 ]
301 logger.info(
302 "Creating public key from private key (%s) via cmd: %s",
303 private_key_path, " ".join(cmd))
304 subprocess.check_call(cmd, stdout=sys.stderr, stderr=sys.stdout)
Fang Deng69498c32017-03-02 14:29:30 -0800305 except subprocess.CalledProcessError as e:
cylan0d77ae12018-05-18 08:36:48 +0000306 raise errors.DriverError("Failed to create ssh key pair: %s" % str(e))
Fang Deng69498c32017-03-02 14:29:30 -0800307 except OSError as e:
308 raise errors.DriverError(
cylan0d77ae12018-05-18 08:36:48 +0000309 "Failed to create ssh key pair, please make sure "
310 "'ssh-keygen' is installed: %s" % str(e))
Fang Deng69498c32017-03-02 14:29:30 -0800311
312 # By default ssh-keygen will create a public key file
313 # by append .pub to the private key file name. Rename it
314 # to what's requested by public_key_path.
315 default_pub_key_path = "%s.pub" % private_key_path
316 try:
317 if default_pub_key_path != public_key_path:
318 os.rename(default_pub_key_path, public_key_path)
319 except OSError as e:
320 raise errors.DriverError(
cylan0d77ae12018-05-18 08:36:48 +0000321 "Failed to rename %s to %s: %s" % (default_pub_key_path,
322 public_key_path, str(e)))
Fang Deng69498c32017-03-02 14:29:30 -0800323
324 logger.info("Created ssh private key (%s) and public key (%s)",
325 private_key_path, public_key_path)
326
327
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700328def VerifyRsaPubKey(rsa):
329 """Verify the format of rsa public key.
330
331 Args:
332 rsa: content of rsa public key. It should follow the format of
333 ssh-rsa AAAAB3NzaC1yc2EA.... test@test.com
334
335 Raises:
336 DriverError if the format is not correct.
337 """
338 if not rsa or not all(ord(c) < 128 for c in rsa):
339 raise errors.DriverError(
340 "rsa key is empty or contains non-ascii character: %s" % rsa)
341
342 elements = rsa.split()
343 if len(elements) != 3:
344 raise errors.DriverError("rsa key is invalid, wrong format: %s" % rsa)
345
346 key_type, data, _ = elements
347 try:
348 binary_data = base64.decodestring(data)
349 # number of bytes of int type
350 int_length = 4
351 # binary_data is like "7ssh-key..." in a binary format.
352 # The first 4 bytes should represent 7, which should be
353 # the length of the following string "ssh-key".
354 # And the next 7 bytes should be string "ssh-key".
355 # We will verify that the rsa conforms to this format.
356 # ">I" in the following line means "big-endian unsigned integer".
357 type_length = struct.unpack(">I", binary_data[:int_length])[0]
358 if binary_data[int_length:int_length + type_length] != key_type:
359 raise errors.DriverError("rsa key is invalid: %s" % rsa)
360 except (struct.error, binascii.Error) as e:
cylan0d77ae12018-05-18 08:36:48 +0000361 raise errors.DriverError(
362 "rsa key is invalid: %s, error: %s" % (rsa, str(e)))
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700363
364
365class BatchHttpRequestExecutor(object):
366 """A helper class that executes requests in batch with retry.
367
368 This executor executes http requests in a batch and retry
369 those that have failed. It iteratively updates the dictionary
370 self._final_results with latest results, which can be retrieved
371 via GetResults.
372 """
373
374 def __init__(self,
375 execute_once_functor,
376 requests,
377 retry_http_codes=None,
378 max_retry=None,
379 sleep=None,
380 backoff_factor=None,
381 other_retriable_errors=None):
382 """Initializes the executor.
383
384 Args:
385 execute_once_functor: A function that execute requests in batch once.
386 It should return a dictionary like
387 {request_id: (response, exception)}
388 requests: A dictionary where key is request id picked by caller,
389 and value is a apiclient.http.HttpRequest.
390 retry_http_codes: A list of http codes to retry.
Fang Dengf24be082018-02-10 10:09:55 -0800391 max_retry: See utils.Retry.
392 sleep: See utils.Retry.
393 backoff_factor: See utils.Retry.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700394 other_retriable_errors: A tuple of error types that should be retried
395 other than errors.HttpError.
396 """
397 self._execute_once_functor = execute_once_functor
398 self._requests = requests
399 # A dictionary that maps request id to pending request.
400 self._pending_requests = {}
401 # A dictionary that maps request id to a tuple (response, exception).
402 self._final_results = {}
403 self._retry_http_codes = retry_http_codes
404 self._max_retry = max_retry
405 self._sleep = sleep
406 self._backoff_factor = backoff_factor
407 self._other_retriable_errors = other_retriable_errors
408
409 def _ShoudRetry(self, exception):
410 """Check if an exception is retriable."""
411 if isinstance(exception, self._other_retriable_errors):
412 return True
413
cylan0d77ae12018-05-18 08:36:48 +0000414 if (isinstance(exception, errors.HttpError)
415 and exception.code in self._retry_http_codes):
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700416 return True
417 return False
418
419 def _ExecuteOnce(self):
420 """Executes pending requests and update it with failed, retriable ones.
421
422 Raises:
423 HasRetriableRequestsError: if some requests fail and are retriable.
424 """
425 results = self._execute_once_functor(self._pending_requests)
426 # Update final_results with latest results.
427 self._final_results.update(results)
428 # Clear pending_requests
429 self._pending_requests.clear()
430 for request_id, result in results.iteritems():
431 exception = result[1]
432 if exception is not None and self._ShoudRetry(exception):
433 # If this is a retriable exception, put it in pending_requests
434 self._pending_requests[request_id] = self._requests[request_id]
435 if self._pending_requests:
436 # If there is still retriable requests pending, raise an error
Fang Dengf24be082018-02-10 10:09:55 -0800437 # so that Retry will retry this function with pending_requests.
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700438 raise errors.HasRetriableRequestsError(
cylan0d77ae12018-05-18 08:36:48 +0000439 "Retriable errors: %s" %
440 [str(results[rid][1]) for rid in self._pending_requests])
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700441
442 def Execute(self):
443 """Executes the requests and retry if necessary.
444
445 Will populate self._final_results.
446 """
cylan0d77ae12018-05-18 08:36:48 +0000447
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700448 def _ShouldRetryHandler(exc):
449 """Check if |exc| is a retriable exception.
450
451 Args:
452 exc: An exception.
453
454 Returns:
455 True if exception is of type HasRetriableRequestsError; False otherwise.
456 """
457 should_retry = isinstance(exc, errors.HasRetriableRequestsError)
458 if should_retry:
459 logger.info("Will retry failed requests.", exc_info=True)
460 logger.info("%s", exc)
461 return should_retry
462
463 try:
464 self._pending_requests = self._requests.copy()
Fang Dengf24be082018-02-10 10:09:55 -0800465 Retry(
cylan0d77ae12018-05-18 08:36:48 +0000466 _ShouldRetryHandler,
467 max_retries=self._max_retry,
Fang Dengf24be082018-02-10 10:09:55 -0800468 functor=self._ExecuteOnce,
469 sleep_multiplier=self._sleep,
470 retry_backoff_factor=self._backoff_factor)
Keun Soo Yimb293fdb2016-09-21 16:03:44 -0700471 except errors.HasRetriableRequestsError:
472 logger.debug("Some requests did not succeed after retry.")
473
474 def GetResults(self):
475 """Returns final results.
476
477 Returns:
478 results, a dictionary in the following format
479 {request_id: (response, exception)}
480 request_ids are those from requests; response
481 is the http response for the request or None on error;
482 exception is an instance of DriverError or None if no error.
483 """
484 return self._final_results