github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/acceptancetests/utility.py (about) 1 from contextlib import contextmanager 2 from datetime import ( 3 datetime, 4 timedelta, 5 ) 6 import errno 7 import json 8 import logging 9 import os 10 import re 11 import subprocess 12 import socket 13 import sys 14 from time import ( 15 sleep, 16 time, 17 ) 18 from jujupy.utility import ( 19 ensure_deleted, 20 ensure_dir, 21 get_timeout_path, 22 get_unit_public_ip, 23 is_ipv6_address, 24 print_now, 25 qualified_model_name, 26 quote, 27 scoped_environ, 28 skip_on_missing_file, 29 temp_dir, 30 temp_yaml_file, 31 until_timeout 32 ) 33 34 # Imported for other call sites to use. 35 __all__ = [ 36 'ensure_deleted', 37 'ensure_dir', 38 'get_timeout_path', 39 'get_unit_public_ip', 40 'qualified_model_name', 41 'quote', 42 'scoped_environ', 43 'skip_on_missing_file', 44 'temp_dir', 45 'temp_yaml_file', 46 ] 47 48 49 # Equivalent of socket.EAI_NODATA when using windows sockets 50 # <https://msdn.microsoft.com/ms740668#WSANO_DATA> 51 WSANO_DATA = 11004 52 53 TEST_MODEL = 'test-tmp-env' 54 55 log = logging.getLogger("utility") 56 57 58 class PortTimeoutError(Exception): 59 pass 60 61 62 class LoggedException(BaseException): 63 """Raised in place of an exception that has already been logged. 64 65 This is a wrapper to avoid double-printing real Exceptions while still 66 unwinding the stack appropriately. 67 """ 68 69 def __init__(self, exception): 70 self.exception = exception 71 72 73 class JujuAssertionError(AssertionError): 74 """Exception for juju assertion failures.""" 75 76 77 def _clean_dir(maybe_dir): 78 """Pseudo-type that validates an argument to be a clean directory path. 79 80 For safety, this function will not attempt to remove existing directory 81 contents but will just report a warning. 82 """ 83 try: 84 contents = os.listdir(maybe_dir) 85 except OSError as e: 86 if e.errno == errno.ENOENT: 87 # we don't raise this error due to tests abusing /tmp/logs 88 logging.warning('Not a directory {}'.format(maybe_dir)) 89 if e.errno == errno.EEXIST: 90 logging.warnings('Directory {} already exists'.format(maybe_dir)) 91 else: 92 if contents and contents != ["empty"]: 93 logging.warning( 94 'Directory {!r} has existing contents.'.format(maybe_dir)) 95 return maybe_dir 96 97 98 def as_literal_address(address): 99 """Returns address in form suitable for embedding in URL or similar. 100 101 In practice, this just puts square brackets round IPv6 addresses which 102 avoids conflict with port seperators and other uses of colons. 103 """ 104 if is_ipv6_address(address): 105 return address.join("[]") 106 return address 107 108 109 def wait_for_port(host, port, closed=False, timeout=30): 110 family = socket.AF_INET6 if is_ipv6_address(host) else socket.AF_INET 111 for remaining in until_timeout(timeout): 112 try: 113 addrinfo = socket.getaddrinfo(host, port, family, 114 socket.SOCK_STREAM) 115 except socket.error as e: 116 if e.errno not in (socket.EAI_NODATA, WSANO_DATA): 117 raise 118 if closed: 119 return 120 else: 121 continue 122 sockaddr = addrinfo[0][4] 123 # Treat Azure messed-up address lookup as a closed port. 124 if sockaddr[0] == '0.0.0.0': 125 if closed: 126 return 127 else: 128 continue 129 conn = socket.socket(*addrinfo[0][:3]) 130 conn.settimeout(max(remaining or 0, 5)) 131 try: 132 conn.connect(sockaddr) 133 except socket.timeout: 134 if closed: 135 return 136 except socket.error as e: 137 if e.errno not in (errno.ECONNREFUSED, errno.ENETUNREACH, 138 errno.ETIMEDOUT, errno.EHOSTUNREACH): 139 raise 140 if closed: 141 return 142 except socket.gaierror as e: 143 print_now(str(e)) 144 except Exception as e: 145 print_now('Unexpected {!r}: {}'.format((type(e), e))) 146 raise 147 else: 148 conn.close() 149 if not closed: 150 return 151 sleep(1) 152 raise PortTimeoutError('Timed out waiting for port.') 153 154 155 def get_revision_build(build_info): 156 for action in build_info['actions']: 157 if 'parameters' in action: 158 for parameter in action['parameters']: 159 if parameter['name'] == 'revision_build': 160 return parameter['value'] 161 162 163 def get_winrm_certs(): 164 """"Returns locations of key and cert files for winrm in cloud-city.""" 165 home = os.environ['HOME'] 166 return ( 167 os.path.join(home, 'cloud-city/winrm_client_cert.key'), 168 os.path.join(home, 'cloud-city/winrm_client_cert.pem'), 169 ) 170 171 172 def s3_cmd(params, drop_output=False): 173 s3cfg_path = os.path.join( 174 os.environ['HOME'], 'cloud-city/juju-qa.s3cfg') 175 command = ['s3cmd', '-c', s3cfg_path, '--no-progress'] + params 176 if drop_output: 177 return subprocess.check_call( 178 command, stdout=open('/dev/null', 'w')) 179 else: 180 return subprocess.check_output(command) 181 182 183 def _get_test_name_from_filename(): 184 try: 185 calling_file = sys._getframe(2).f_back.f_globals['__file__'] 186 return os.path.splitext(os.path.basename(calling_file))[0] 187 except: 188 return 'unknown_test' 189 190 191 def generate_default_clean_dir(temp_env_name): 192 """Creates a new unique directory for logging and returns name""" 193 logging.debug('Environment {}'.format(temp_env_name)) 194 test_name = temp_env_name.split('-')[0] 195 timestamp = datetime.now().strftime("%Y%m%d%H%M%S") 196 log_dir = os.path.join('/tmp', test_name, 'logs', timestamp) 197 198 try: 199 os.makedirs(log_dir) 200 logging.info('Created logging directory {}'.format(log_dir)) 201 except OSError as e: 202 if e.errno == errno.EEXIST: 203 logging.warn('"Directory {} already exists'.format(log_dir)) 204 else: 205 raise('Failed to create logging directory: {} ' + 206 log_dir + 207 '. Please specify empty folder or try again') 208 return log_dir 209 210 211 def _generate_default_temp_env_name(): 212 """Creates a new unique name for environment and returns the name""" 213 # we need to sanitize the name 214 timestamp = datetime.now().strftime("%Y%m%d%H%M%S") 215 test_name = re.sub('[^a-zA-Z]', '', _get_test_name_from_filename()) 216 return '{}-{}-temp-env'.format(test_name, timestamp) 217 218 219 def _to_deadline(timeout): 220 return datetime.utcnow() + timedelta(seconds=int(timeout)) 221 222 223 def add_arg_juju_bin(parser): 224 parser.add_argument('juju_bin', nargs='?', 225 help='Full path to the Juju binary. By default, this' 226 ' will use $PATH/juju', 227 default=None) 228 229 230 def add_basic_testing_arguments( 231 parser, using_jes=False, deadline=True, env=True, existing=True): 232 """Returns the parser loaded with basic testing arguments. 233 234 The basic testing arguments, used in conjuction with boot_context ensures 235 a test can be run in any supported substrate in parallel. 236 237 This helper adds 4 positional arguments that defines the minimum needed 238 to run a test script. 239 240 These arguments (env, juju_bin, logs, temp_env_name) allow you to specify 241 specifics for which env, juju binary, which folder for logging and an 242 environment name for your test respectively. 243 244 There are many optional args that either update the env's config or 245 manipulate the juju command line options to test in controlled situations 246 or in uncommon substrates: --debug, --verbose, --agent-url, --agent-stream, 247 --series, --bootstrap-host, --machine, --keep-env. If not using_jes, the 248 --upload-tools arg will also be added. 249 250 :param parser: an ArgumentParser. 251 :param using_jes: whether args should be tailored for JES testing. 252 :param deadline: If true, support the --timeout option and convert to a 253 deadline. 254 :param existing: If true will supply the 'existing' argument to allow 255 running on an existing bootstrapped controller. 256 """ 257 258 # Optional postional arguments 259 if env: 260 parser.add_argument( 261 'env', nargs='?', 262 help='The juju environment to base the temp test environment on.', 263 default='lxd') 264 add_arg_juju_bin(parser) 265 parser.add_argument('logs', nargs='?', type=_clean_dir, 266 help='A directory in which to store logs. By default,' 267 ' this will use the current directory', 268 default=None) 269 parser.add_argument('temp_env_name', nargs='?', 270 help='A temporary test environment name. By default, ' 271 ' this will generate an enviroment name using the ' 272 ' timestamp and testname. ' 273 ' test_name_timestamp_temp_env', 274 default=_generate_default_temp_env_name()) 275 276 # Optional keyword arguments. 277 parser.add_argument('--debug', action='store_true', 278 help='Pass --debug to Juju.') 279 parser.add_argument('--verbose', action='store_const', 280 default=logging.INFO, const=logging.DEBUG, 281 help='Verbose test harness output.') 282 parser.add_argument('--region', help='Override environment region.') 283 parser.add_argument('--to', default=None, 284 help='Place the controller at a location.') 285 parser.add_argument('--agent-url', action='store', default=None, 286 help='URL for retrieving agent binaries.') 287 parser.add_argument('--agent-stream', action='store', default=None, 288 help='Stream for retrieving agent binaries.') 289 parser.add_argument('--series', action='store', default=None, 290 help='Name of the Ubuntu series to use.') 291 if not using_jes: 292 parser.add_argument('--upload-tools', action='store_true', 293 help='upload local version of tools to bootstrap.') 294 parser.add_argument('--bootstrap-host', 295 help='The host to use for bootstrap.') 296 parser.add_argument('--machine', help='A machine to add or when used with ' 297 'KVM based MaaS, a KVM image to start.', 298 action='append', default=[]) 299 parser.add_argument('--keep-env', action='store_true', 300 help='Keep the Juju environment after the test' 301 ' completes.') 302 parser.add_argument('--logging-config', 303 help="Override logging configuration for a deployment.", 304 default="<root>=INFO;unit=INFO") 305 if existing: 306 parser.add_argument( 307 '--existing', 308 action='store', 309 default=None, 310 const='current', 311 nargs='?', 312 help='Test using an existing bootstrapped controller. ' 313 'If no controller name is provided defaults to using the ' 314 'current selected controller.') 315 if deadline: 316 parser.add_argument('--timeout', dest='deadline', type=_to_deadline, 317 help="The script timeout, in seconds.") 318 return parser 319 320 321 # suppress nosetests 322 add_basic_testing_arguments.__test__ = False 323 324 325 def configure_logging(log_level): 326 logging.basicConfig( 327 level=log_level, format='%(asctime)s %(levelname)s %(message)s', 328 datefmt='%Y-%m-%d %H:%M:%S') 329 330 331 def get_candidates_path(root_dir): 332 return os.path.join(root_dir, 'candidate') 333 334 335 # GZ 2015-10-15: Paths returned in filesystem dependent order, may want sort? 336 def find_candidates(root_dir, find_all=False): 337 return (path for path, buildvars in _find_candidates(root_dir, find_all)) 338 339 340 def find_latest_branch_candidates(root_dir): 341 """Return a list of one candidate per branch. 342 343 :param root_dir: The root directory to find candidates from. 344 """ 345 candidates = [] 346 for path, buildvars_path in _find_candidates(root_dir, find_all=False, 347 artifacts=True): 348 with open(buildvars_path) as buildvars_file: 349 buildvars = json.load(buildvars_file) 350 candidates.append( 351 (buildvars['branch'], int(buildvars['revision_build']), path)) 352 latest = dict( 353 (branch, (path, build)) for branch, build, path in sorted(candidates)) 354 return latest.values() 355 356 357 def _find_candidates(root_dir, find_all=False, artifacts=False): 358 candidates_path = get_candidates_path(root_dir) 359 a_week_ago = time() - timedelta(days=7).total_seconds() 360 for candidate_dir in os.listdir(candidates_path): 361 if candidate_dir.endswith('-artifacts') != artifacts: 362 continue 363 candidate_path = os.path.join(candidates_path, candidate_dir) 364 buildvars = os.path.join(candidate_path, 'buildvars.json') 365 try: 366 stat = os.stat(buildvars) 367 except OSError as e: 368 if e.errno in (errno.ENOENT, errno.ENOTDIR): 369 continue 370 raise 371 if not find_all and stat.st_mtime < a_week_ago: 372 continue 373 yield candidate_path, buildvars 374 375 376 def get_deb_arch(): 377 """Get the debian machine architecture.""" 378 return subprocess.check_output(['dpkg', '--print-architecture']).strip() 379 380 381 def extract_deb(package_path, directory): 382 """Extract a debian package to a specified directory.""" 383 subprocess.check_call(['dpkg', '-x', package_path, directory]) 384 385 386 def run_command(command, dry_run=False, verbose=False): 387 """Optionally execute a command and maybe print the output.""" 388 if verbose: 389 print_now('Executing: {}'.format(command)) 390 if not dry_run: 391 output = subprocess.check_output(command) 392 if verbose: 393 print_now(output) 394 395 396 def log_and_wrap_exception(logger, exc): 397 """Record exc details to logger and return wrapped in LoggedException.""" 398 logger.exception(exc) 399 stdout = getattr(exc, 'output', None) 400 stderr = getattr(exc, 'stderr', None) 401 if stdout or stderr: 402 logger.info('Output from exception:\nstdout:\n%s\nstderr:\n%s', 403 stdout, stderr) 404 return LoggedException(exc) 405 406 407 @contextmanager 408 def logged_exception(logger): 409 """\ 410 Record exceptions in managed context to logger and reraise LoggedException. 411 412 Note that BaseException classes like SystemExit, GeneratorExit and 413 LoggedException itself are not wrapped, except for KeyboardInterrupt. 414 """ 415 try: 416 yield 417 except (Exception, KeyboardInterrupt) as e: 418 raise log_and_wrap_exception(logger, e) 419 420 421 def assert_dict_is_subset(sub_dict, super_dict): 422 """Assert that every item in the sub_dict is in the super_dict. 423 424 :raises JujuAssertionError: when sub_dict items are missing. 425 :return: True when when sub_dict is a subset of super_dict 426 """ 427 if not all(item in super_dict.items() for item in sub_dict.items()): 428 raise JujuAssertionError( 429 'Found: {} \nExpected: {}'.format(super_dict, sub_dict)) 430 return True 431 432 433 def add_model(client): 434 """Adds a model to the current juju environment then destroys it. 435 436 Will raise an exception if the Juju does not deselect the current model. 437 :param client: Jujupy ModelClient object 438 """ 439 log.info('Adding model "{}" to current controller'.format(TEST_MODEL)) 440 new_client = client.add_model(TEST_MODEL) 441 new_model = get_current_model(new_client) 442 if new_model == TEST_MODEL: 443 log.info('Current model and newly added model match') 444 else: 445 error = ('Juju failed to switch to new model after creation. ' 446 'Expected {} got {}'.format(TEST_MODEL, new_model)) 447 raise JujuAssertionError(error) 448 return new_client 449 450 451 def get_current_model(client): 452 """Gets the current model from Juju's list-models command. 453 454 :param client: Jujupy ModelClient object 455 :return: String name of current model 456 """ 457 raw = list_models(client) 458 try: 459 return raw['current-model'] 460 except KeyError: 461 log.warning('No model is currently selected.') 462 return None 463 464 465 def list_models(client): 466 """List models. 467 :param client: Jujupy ModelClient object 468 :return: Dict of list-models command 469 """ 470 try: 471 raw = client.get_juju_output('list-models', '--format', 'json', 472 include_e=False).decode('utf-8') 473 except subprocess.CalledProcessError as e: 474 log.error('Failed to list current models due to error: {}'.format(e)) 475 raise e 476 return json.loads(raw)