github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/acceptancetests/jujupy/backend.py (about) 1 # This file is part of JujuPy, a library for driving the Juju CLI. 2 # Copyright 2013-2017 Canonical Ltd. 3 # 4 # This program is free software: you can redistribute it and/or modify it 5 # under the terms of the Lesser GNU General Public License version 3, as 6 # published by the Free Software Foundation. 7 # 8 # This program is distributed in the hope that it will be useful, but WITHOUT 9 # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, 10 # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the Lesser 11 # GNU General Public License for more details. 12 # 13 # You should have received a copy of the Lesser GNU General Public License 14 # along with this program. If not, see <http://www.gnu.org/licenses/>. 15 16 import logging 17 import json 18 import os 19 import pexpect 20 import subprocess 21 22 from contextlib import contextmanager 23 from datetime import datetime 24 25 from jujupy.exceptions import ( 26 CannotConnectEnv, 27 NoActiveControllers, 28 NoActiveModel, 29 SoftDeadlineExceeded, 30 ) 31 from jujupy.utility import ( 32 get_timeout_path, 33 get_timeout_prefix, 34 pause, 35 quote, 36 scoped_environ, 37 ) 38 from jujupy.wait_condition import ( 39 CommandTime, 40 ) 41 42 __metaclass__ = type 43 44 # Python 2 and 3 compatibility 45 try: 46 argtype = basestring 47 except NameError: 48 argtype = str 49 50 log = logging.getLogger("jujupy.backend") 51 52 JUJU_DEV_FEATURE_FLAGS = 'JUJU_DEV_FEATURE_FLAGS' 53 54 55 class JujuBackend: 56 """A Juju backend referring to a specific juju 2 binary. 57 58 Uses -m to specify models, uses JUJU_DATA to specify home directory. 59 """ 60 61 _model_flag = '-m' 62 63 def __init__(self, full_path, version, feature_flags, debug, 64 soft_deadline=None): 65 self._version = version 66 self._full_path = full_path 67 self.feature_flags = feature_flags 68 self.debug = debug 69 self._timeout_path = get_timeout_path() 70 self.juju_timings = [] 71 self.soft_deadline = soft_deadline 72 self._ignore_soft_deadline = False 73 # List of ModelClients, keep track of models added so we can remove 74 # only those added during a test run (i.e. when using an existing 75 # controller.) 76 self._added_models = [] 77 78 def _now(self): 79 return datetime.utcnow() 80 81 @contextmanager 82 def _check_timeouts(self): 83 # If an exception occurred, we don't want to replace it with 84 # SoftDeadlineExceeded. 85 yield 86 if self.soft_deadline is None or self._ignore_soft_deadline: 87 return 88 if self._now() > self.soft_deadline: 89 raise SoftDeadlineExceeded() 90 91 @contextmanager 92 def ignore_soft_deadline(self): 93 """Ignore the client deadline. For cleanup code.""" 94 old_val = self._ignore_soft_deadline 95 self._ignore_soft_deadline = True 96 try: 97 yield 98 finally: 99 self._ignore_soft_deadline = old_val 100 101 def clone(self, full_path, version, debug, feature_flags): 102 if version is None: 103 version = self.version 104 if full_path is None: 105 full_path = self.full_path 106 if debug is None: 107 debug = self.debug 108 result = self.__class__(full_path, version, feature_flags, debug, 109 self.soft_deadline) 110 # Each clone shares a reference to juju_timings allowing us to collect 111 # all commands run during a test. 112 result.juju_timings = self.juju_timings 113 114 # Each clone shares a reference to _added_models to ensure we track any 115 # added models regardless of the ModelClient that adds them. 116 result._added_models = self._added_models 117 return result 118 119 def track_model(self, client): 120 # Keep a reference to `client` for the lifetime of this backend (or 121 # until it's untracked). 122 self._added_models.append(client) 123 124 def untrack_model(self, client): 125 """Remove `client` from tracking. Silently fails if not present.""" 126 # No longer need to track this client for whatever reason. 127 try: 128 self._added_models.remove(client) 129 except ValueError: 130 log.debug( 131 'Attempted to remove client "{}" that was not tracked.'.format( 132 client.env.environment)) 133 pass 134 135 @property 136 def version(self): 137 return self._version 138 139 @property 140 def full_path(self): 141 return self._full_path 142 143 @property 144 def juju_name(self): 145 return os.path.basename(self._full_path) 146 147 @property 148 def added_models(self): 149 # Return a copy of the list so any modifications don't trip callees up. 150 return list(self._added_models) 151 152 def _get_attr_tuple(self): 153 return (self._version, self._full_path, self.feature_flags, 154 self.debug, self.juju_timings) 155 156 def __eq__(self, other): 157 if type(self) != type(other): 158 return False 159 return self._get_attr_tuple() == other._get_attr_tuple() 160 161 def shell_environ(self, used_feature_flags, juju_home): 162 """Generate a suitable shell environment. 163 164 Juju's directory must be in the PATH to support plugins. 165 """ 166 env = dict(os.environ) 167 if self.full_path is not None: 168 env['PATH'] = '{}{}{}'.format(os.path.dirname(self.full_path), 169 os.pathsep, env['PATH']) 170 flags = self.feature_flags.intersection(used_feature_flags) 171 feature_flag_string = env.get(JUJU_DEV_FEATURE_FLAGS, '') 172 if feature_flag_string != '': 173 flags.update(feature_flag_string.split(',')) 174 if flags: 175 env[JUJU_DEV_FEATURE_FLAGS] = ','.join(sorted(flags)) 176 env['JUJU_DATA'] = juju_home 177 return env 178 179 def full_args(self, command, args, model, timeout): 180 if model is not None: 181 e_arg = (self._model_flag, model) 182 else: 183 e_arg = () 184 if timeout is None: 185 prefix = () 186 else: 187 prefix = get_timeout_prefix(timeout, self._timeout_path) 188 logging = '--debug' if self.debug else '--show-log' 189 190 # If args is a string, make it a tuple. This makes writing commands 191 # with one argument a bit nicer. 192 if isinstance(args, argtype): 193 args = (args,) 194 # we split the command here so that the caller can control where the -m 195 # model flag goes. Everything in the command string is put before the 196 # -m flag. 197 command = command.split() 198 return (prefix + (self.juju_name, logging,) + tuple(command) + e_arg + 199 args) 200 201 def juju(self, command, args, used_feature_flags, 202 juju_home, model=None, check=True, timeout=None, extra_env=None, 203 suppress_err=False): 204 """Run a command under juju for the current environment. 205 206 :return: Tuple rval, CommandTime rval being the commands exit code and 207 a CommandTime object used for storing command timing data. 208 """ 209 args = self.full_args(command, args, model, timeout) 210 log.info(' '.join(args)) 211 env = self.shell_environ(used_feature_flags, juju_home) 212 if extra_env is not None: 213 env.update(extra_env) 214 if check: 215 call_func = subprocess.check_call 216 else: 217 call_func = subprocess.call 218 # Mutate os.environ instead of supplying env parameter so Windows can 219 # search env['PATH'] 220 stderr = subprocess.PIPE if suppress_err else None 221 # Keep track of commands and how long the take. 222 command_time = CommandTime(command, args, env) 223 with scoped_environ(env): 224 log.debug('Running juju with env: {}'.format(env)) 225 with self._check_timeouts(): 226 rval = call_func(args, stderr=stderr) 227 self.juju_timings.append(command_time) 228 return rval, command_time 229 230 def expect(self, command, args, used_feature_flags, juju_home, model=None, 231 timeout=None, extra_env=None): 232 args = self.full_args(command, args, model, timeout) 233 log.info(' '.join(args)) 234 env = self.shell_environ(used_feature_flags, juju_home) 235 if extra_env is not None: 236 env.update(extra_env) 237 # pexpect.spawn expects a string. This is better than trying to extract 238 # command + args from the returned tuple (as there could be an intial 239 # timing command tacked on). 240 command_string = ' '.join(quote(a) for a in args) 241 with scoped_environ(env): 242 return pexpect.spawn(command_string) 243 244 @contextmanager 245 def juju_async(self, command, args, used_feature_flags, 246 juju_home, model=None, timeout=None): 247 full_args = self.full_args(command, args, model, timeout) 248 log.info(' '.join(args)) 249 env = self.shell_environ(used_feature_flags, juju_home) 250 # Mutate os.environ instead of supplying env parameter so Windows can 251 # search env['PATH'] 252 with scoped_environ(env): 253 with self._check_timeouts(): 254 proc = subprocess.Popen(full_args) 255 yield proc 256 retcode = proc.wait() 257 if retcode != 0: 258 raise subprocess.CalledProcessError(retcode, full_args) 259 260 def get_juju_output(self, command, args, used_feature_flags, juju_home, 261 model=None, timeout=None, user_name=None, 262 merge_stderr=False): 263 args = self.full_args(command, args, model, timeout) 264 env = self.shell_environ(used_feature_flags, juju_home) 265 log.debug(args) 266 # Mutate os.environ instead of supplying env parameter so 267 # Windows can search env['PATH'] 268 with scoped_environ(env): 269 proc = subprocess.Popen( 270 args, stdout=subprocess.PIPE, stdin=subprocess.PIPE, 271 stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE) 272 with self._check_timeouts(): 273 sub_output, sub_error = proc.communicate() 274 log.debug(sub_output) 275 if proc.returncode != 0: 276 log.debug(sub_error) 277 e = subprocess.CalledProcessError( 278 proc.returncode, args, sub_output) 279 e.stderr = sub_error 280 if sub_error and ( 281 b'Unable to connect to environment' in sub_error or 282 b'MissingOrIncorrectVersionHeader' in sub_error or 283 b'307: Temporary Redirect' in sub_error): 284 raise CannotConnectEnv(e) 285 raise e 286 return sub_output 287 288 def get_active_model(self, juju_data_dir): 289 """Determine the active model in a juju data dir.""" 290 try: 291 current = json.loads(self.get_juju_output( 292 'models', ('--format', 'json'), set(), 293 juju_data_dir, model=None).decode('ascii')) 294 except subprocess.CalledProcessError: 295 raise NoActiveControllers( 296 'No active controller for {}'.format(juju_data_dir)) 297 try: 298 return current['current-model'] 299 except KeyError: 300 raise NoActiveModel('No active model for {}'.format(juju_data_dir)) 301 302 def get_active_controller(self, juju_data_dir): 303 """Determine the active controller in a juju data dir.""" 304 try: 305 current = json.loads(self.get_juju_output( 306 'controllers', ('--format', 'json'), set(), 307 juju_data_dir, model=None).decode('ascii')) 308 except subprocess.CalledProcessError: 309 raise NoActiveControllers( 310 'No active controller for {}'.format(juju_data_dir)) 311 try: 312 return current['current-controller'] 313 except KeyError: 314 raise NoActiveControllers( 315 'No active controller for {}'.format(juju_data_dir)) 316 317 def get_active_user(self, juju_data_dir, controller): 318 """Determine the active user for a controller.""" 319 try: 320 current = json.loads(self.get_juju_output( 321 'controllers', ('--format', 'json'), set(), 322 juju_data_dir, model=None).decode('ascii')) 323 except subprocess.CalledProcessError: 324 raise NoActiveControllers( 325 'No active controller for {}'.format(juju_data_dir)) 326 return current['controllers'][controller]['user'] 327 328 def pause(self, seconds): 329 pause(seconds)