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