def test_logger_can_log(): """Test: logger can log Assertions ---------- - _log method should be called with 40, 'foo' """ # pylint: disable=protected-access logger = Logger(LOGGER_NAME).get_logger() logger._log = Mock() logger.error('foo') logger._log.assert_called_with(40, 'foo', ())
class ManagementClient(object): """A class used as a management client for BIG-IP Attributes ---------- host : str the hostname of the device port : str the port of the device token : str the token of the device token_details : dict the token details of the device logger : object instantiated logger object Methods ------- get_info() Refer to method documentation make_request() Refer to method documentation make_ssh_request() Refer to method documentation """ def __init__(self, host, **kwargs): """Class initialization Parameters ---------- host : str the hostname of the device **kwargs : optional keyword arguments Keyword Arguments ----------------- port : int the port to assign to the port attribute user : str the username for device authentication password : str the password for device authentication private_key_file : str the file containing the private key for device authentication set_user_password : str sets the user password to this value - used along with private_key_file token : str the token to assign to the token attribute skip_ready_check : bool skips the device ready check if set to true Returns ------- None """ self.logger = Logger(__name__).get_logger() self.host = host.split(':')[0] # disallow providing port here self.port = int(kwargs.pop('port', None) or self._discover_port()) self._user = kwargs.pop('user', None) self._password = kwargs.pop('password', None) self._private_key_file = kwargs.pop('private_key_file', None) self._set_user_password = kwargs.pop('set_user_password', None) self.token = kwargs.pop('token', None) self.token_details = {} # check device is ready if not kwargs.pop('skip_ready_check', False): self._is_ready() # handle multiple authentication mechanisms if self._user and self._password: self._login_using_credentials() elif self._user and self._private_key_file: self._set_password_using_key() self._login_using_credentials() elif self.token: pass else: raise Exception( 'user|password, user|private_key_file or token required') def _test_socket(self, port): """Test TCP connection can be established Parameters ---------- None Returns ------- bool a boolean true/false """ _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) _socket.settimeout(1) check = False try: _socket.connect((self.host, port)) check = True except (socket.timeout, OSError) as err: self.logger.debug('connection timeout: %s', err) finally: _socket.close() return check def _discover_port(self): """Discover management port (best effort) Try 443 -> 8443, set port to 443 if neither responds. Timeout set to 1 second, if connect or connect refused assume it is the right port. Parameters ---------- None Keyword Arguments ----------------- None Returns ------- int the discovered management port """ if self._test_socket(DFL_PORT): return DFL_PORT if self._test_socket(DFL_PORT_1NIC): return DFL_PORT_1NIC return DFL_PORT @retry(tries=constants.RETRIES['LONG'], delay=constants.RETRIES['DELAY_IN_SECS']) def _is_ready(self): """Checks that the device is ready Notes ----- Retries up to 5 minutes Parameters ---------- None Returns ------- bool boolean true if device is ready """ self.logger.debug('Performing ready check using port %s' % self.port) if self._test_socket(self.port): return True raise DeviceReadyError('Unable to complete device ready check') def _make_ssh_request(self, command): """See public method for documentation: make_ssh_request """ # note: command *might* contain sensitive information # logger should scrub those: i.e. secret foo -> secret *** self.logger.debug('Making SSH request: %s' % (command)) # create client kwargs client_kwargs = {'username': self._user} if self._password: client_kwargs['password'] = self._password elif self._private_key_file: private_key_file = os.path.expanduser(self._private_key_file) client_kwargs['pkey'] = paramiko.RSAKey.from_private_key_file( private_key_file) else: raise Exception('password or private key file required') # workaround for deprecation warning described here, until fixed # https://github.com/paramiko/paramiko/issues/1369 # workaround: temporarily catch warnings on client.connect with warnings.catch_warnings(record=True) as _: # create client client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.client.AutoAddPolicy) try: client.connect(self.host, **client_kwargs) except SSH_EXCEPTIONS as _e: self.logger.error(_e) raise _e # collect result result = client.exec_command(command) # command output (tuple): stdin, stdout, stder stdout = result[1].read().decode('utf-8') stderr = result[2].read().decode('utf-8') client.close() if stderr: raise SSHCommandStdError('Error: %s' % stderr) return stdout.rstrip('\n\r') @retry(tries=constants.RETRIES['DEFAULT'], delay=constants.RETRIES['DELAY_IN_SECS']) def _set_password_using_key(self): """Sets password on device using user + private key Updates user's password using set_user_password Retries if unsuccessful, up to maximum allotment Parameters ---------- None Returns ------- None """ # get password to set password = self._set_user_password if not password: raise Exception('set_user_password required') # get user shell - tmsh or bash tmsh = '' # note: if the shell is in fact bash the first command will fail, so catch # the exception and try with 'tmsh' explicitly added to the command auth_list_cmd = constants.BIGIP_CMDS['AUTH_LIST'] try: user_info = self._make_ssh_request(auth_list_cmd % (tmsh, self._user)) except SSHCommandStdError: user_info = self._make_ssh_request(auth_list_cmd % ('tmsh', self._user)) if 'shell bash' in user_info: tmsh = 'tmsh' # add tmsh to command # set password self._make_ssh_request(constants.BIGIP_CMDS['AUTH_MODIFY'] % (tmsh, self._user, password)) self._password = password @retry(exceptions=HTTPError, tries=constants.RETRIES['DEFAULT'], delay=constants.RETRIES['DELAY_IN_SECS']) def _get_token(self): """Gets authentication token Retries if unsuccessful, up to maximum allotment Parameters ---------- None Returns ------- dict a dictionary containing authentication token, expiration date and expiration in seconds: {'token': 'token', 'expirationDate': '2019-01-01T01:01:01.00', 'expirationIn': 3600} """ self.logger.debug('Getting authentication token') expiration_date = (datetime.now() + timedelta(hours=1)).isoformat() timeout = 3600 # set timeout to 1 hour uri = '/mgmt/shared/authn/login' body = { 'username': self._user, 'password': self._password, 'loginProviderName': 'tmos' } # get token try: response = http_utils.make_request(self.host, uri, port=self.port, method='POST', body=body, basic_auth={ 'user': self._user, 'password': self._password }) except HTTPError as error: if constants.HTTP_STATUS_CODE['FAILED_AUTHENTICATION'] in str( error): _exception = InvalidAuthError(error) _exception.__cause__ = None raise _exception raise error token = response['token']['token'] # now extend token lifetime token_uri = '/mgmt/shared/authz/tokens/%s' % token http_utils.make_request(self.host, token_uri, port=self.port, method='PATCH', body={'timeout': timeout}, basic_auth={ 'user': self._user, 'password': self._password }) return { 'token': token, 'expirationDate': expiration_date, 'expirationIn': timeout } def _login_using_credentials(self): """Logs in to device using user + password Parameters ---------- None Returns ------- None """ self.logger.debug('Logging in using user + password') token = self._get_token() self.token = token['token'] self.token_details = token @check_auth @add_auth_header def make_request(self, uri, **kwargs): """Makes request to device (HTTP/S) Parameters ---------- uri : str the URI where the request should be made **kwargs : optional keyword arguments Keyword Arguments ----------------- method : str the HTTP method to use headers : str the HTTP headers to use body : str the HTTP body to use body_content_type : str the HTTP body content type to use bool_response : bool return boolean based on HTTP success/failure advanced_return : bool return additional information, like HTTP status code to caller Returns ------- dict a dictionary containing the JSON response """ return http_utils.make_request(self.host, uri, port=self.port, **kwargs) @check_auth def make_ssh_request(self, command): """Makes request to device (SSH) Parameters ---------- command : str the command to execute on the device Returns ------- str the command response """ return self._make_ssh_request(command) def get_info(self): """Gets device info Parameters ---------- None Returns ------- dict the device information :: { 'version': 'x.x.x.x' } """ uri = '/mgmt/tm/sys/version' response = self.make_request(uri) version = response['entries'][ 'https://localhost/mgmt/tm/sys/version/0']['nestedStats'][ 'entries']['Version']['description'] return {'version': version}