def __init__(self, hostname, vdom=None, username=None, password=None, keyfile=None, timeout=60, port=22): """ Represents a device running FortiOS. A :py:class:`FortiOS` object has three different :class:`~pyFG.forticonfig.FortiConfig` objects: * **running_config** -- You can populate this from the device or from a file with the\ :func:`~pyFG.fortios.FortiOS.load_config` method. This will represent the live config\ of the device and shall not be modified by any means as that might break other methods as the \ :func:`~pyFG.fortios.FortiOS.commit` * **candidate_config** -- You can populate this using the same mechanisms as you would populate the\ running_config. This represents the config you want to reach so, if you want to apply\ changes, here is where you would apply them. * **original_config** -- This is automatically populated when you do a commit with the original config\ prior to the commit. This is useful for the :func:`~pyFG.fortios.FortiOS.rollback` operation or for\ checking stuff later on. Args: * **hostname** (str) -- FQDN or IP of the device you want to connect. * **vdom** (str) -- VDOM you want to connect to. If it is None we will run the commands without moving\ to a VDOM. * **username** (str) -- Username to connect to the device. If none is specified the current user will be\ used * **password** (str) -- Username password * **keyfile** (str) -- Path to the private key in case you want to use this authentication method. * **timeout** (int) -- Time in seconds to wait for the device to respond. * **port** (int) -- Port of the device you want to connect. """ self.hostname = hostname self.vdom = vdom self.original_config = None self.running_config = FortiConfig('running', vdom=vdom) self.candidate_config = FortiConfig('candidate', vdom=vdom) self.ssh = None self.username = username self.password = password self.keyfile = keyfile self.timeout = timeout self.port = port # Set key exchange explcitly to address known fortinet issue paramiko.Transport._preferred_kex = ( 'diffie-hellman-group14-sha1', 'diffie-hellman-group-exchange-sha1', 'diffie-hellman-group-exchange-sha256', 'diffie-hellman-group1-sha1', )
def test_commit_with_retry_when_adding(self): # First we make sure we don't have the config we want to add starting_config = ''' conf vdom edit test_vdom config router bgp config neighbor del "172.20.213.23" edit "2.2.2.2" set remote-as 123 set shutdown enable end end end config router route-map del "test4" edit "dummy" set comments "asd" next end end ''' try: self.device.commit(config_text=starting_config) except FailedCommit: pass self.device.load_config('router bgp') self.device.load_config('router route-map') neigh = FortiConfig('172.20.213.23', 'edit') neigh.set_param('remote-as', 65555) neigh.set_param('route-map-in', 'test4') self.device.candidate_config['router bgp']['neighbor'][ '172.20.213.23'] = neigh route_map = FortiConfig('test4', 'edit') route_map.set_param('comments', '"bla bla bla"') self.device.candidate_config['router route-map']['test4'] = route_map pre_diff = self.device.compare_config() self.device.commit() post_diff = self.device.compare_config() # The previous diff should contain something self.assertGreater(len(pre_diff), 0) # The diff after the commit should be empty self.assertEqual(len(post_diff), 0)
def _reload_config(self, reload_original_config): """ This command will update the running config from the live device. Args: * reload_original_config: * If ``True`` the original config will be loaded with the running config before reloading the\ original config. * If ``False`` the original config will remain untouched. """ # We don't want to reload the config under some circumstances if reload_original_config: self.original_config = self.running_config self.original_config.set_name('original') paths = self.running_config.get_paths() self.running_config = FortiConfig('running', vdom=self.vdom) for path in paths: self.load_config(path, empty_candidate=True)
def test_commit_with_retry_when_adding(self): # First we make sure we don't have the config we want to add starting_config = """ conf vdom edit test_vdom config router bgp config neighbor del "172.20.213.23" edit "2.2.2.2" set remote-as 123 set shutdown enable end end end config router route-map del "test4" edit "dummy" set comments "asd" next end end """ try: self.device.commit(config_text=starting_config) except FailedCommit: pass self.device.load_config("router bgp") self.device.load_config("router route-map") neigh = FortiConfig("172.20.213.23", "edit") neigh.set_param("remote-as", 65555) neigh.set_param("route-map-in", "test4") self.device.candidate_config["router bgp"]["neighbor"]["172.20.213.23"] = neigh route_map = FortiConfig("test4", "edit") route_map.set_param("comments", '"bla bla bla"') self.device.candidate_config["router route-map"]["test4"] = route_map pre_diff = self.device.compare_config() self.device.commit() post_diff = self.device.compare_config() # The previous diff should contain something self.assertGreater(len(pre_diff), 0) # The diff after the commit should be empty self.assertEqual(len(post_diff), 0)
class FortiOS(object): def __init__(self, hostname, vdom=None, username=None, password=None, keyfile=None, timeout=60): """ Represents a device running FortiOS. A :py:class:`FortiOS` object has three different :class:`~pyFG.forticonfig.FortiConfig` objects: * **running_config** -- You can populate this from the device or from a file with the\ :func:`~pyFG.fortios.FortiOS.load_config` method. This will represent the live config\ of the device and shall not be modified by any means as that might break other methods as the \ :func:`~pyFG.fortios.FortiOS.commit` * **candidate_config** -- You can populate this using the same mechanisms as you would populate the\ running_config. This represents the config you want to reach so, if you want to apply\ changes, here is where you would apply them. * **original_config** -- This is automatically populated when you do a commit with the original config\ prior to the commit. This is useful for the :func:`~pyFG.fortios.FortiOS.rollback` operation or for\ checking stuff later on. Args: * **hostname** (str) -- FQDN or IP of the device you want to connect. * **vdom** (str) -- VDOM you want to connect to. If it is None we will run the commands without moving\ to a VDOM. * **username** (str) -- Username to connect to the device. If none is specified the current user will be\ used * **password** (str) -- Username password * **keyfile** (str) -- Path to the private key in case you want to use this authentication method. * **timeout** (int) -- Time in seconds to wait for the device to respond. """ self.hostname = hostname self.vdom = vdom self.original_config = None self.running_config = FortiConfig('running', vdom=vdom) self.candidate_config = FortiConfig('candidate', vdom=vdom) self.ssh = None self.username = username self.password = password self.keyfile = keyfile self.timeout = timeout # Set key exchange explcitly to address known fortinet issue paramiko.Transport._preferred_kex = ( 'diffie-hellman-group14-sha1', 'diffie-hellman-group-exchange-sha1', 'diffie-hellman-group-exchange-sha256', 'diffie-hellman-group1-sha1', ) def open(self): """ Opens the ssh session with the device. """ logger.debug('Connecting to device %s, vdom %s' % (self.hostname, self.vdom)) self.ssh = paramiko.SSHClient() self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) cfg = { 'hostname': self.hostname, 'timeout': self.timeout, 'username': self.username, 'password': self.password, 'key_filename': self.keyfile } if os.path.exists(os.path.expanduser("~/.ssh/config")): ssh_config = paramiko.SSHConfig() user_config_file = os.path.expanduser("~/.ssh/config") with io.open(user_config_file, 'rt', encoding='utf-8') as f: ssh_config.parse(f) host_conf = ssh_config.lookup(self.hostname) if host_conf: if 'proxycommand' in host_conf: cfg['sock'] = paramiko.ProxyCommand( host_conf['proxycommand']) if 'user' in host_conf: cfg['username'] = host_conf['user'] if 'identityfile' in host_conf: cfg['key_filename'] = host_conf['identityfile'] if 'hostname' in host_conf: cfg['hostname'] = host_conf['hostname'] self.ssh.connect(**cfg) def close(self): """ Closes the ssh session with the device. """ logger.debug('Closing connection to device %s' % self.hostname) self.ssh.close() @staticmethod def _read_wrapper(data): """Ensure unicode always returned on read.""" # Paramiko (strangely) in PY3 returns an int here. if isinstance(data, int): data = chr(data) # Ensure unicode return py23_compat.text_type(data) def execute_command(self, command): """ This method will execute the commands on the device without as if you were just connected to it (it will not enter into any vdom). This method is not recommended unless you are 100% sure of what you are doing. Args: * **command** (str) -- Command to execute. Returns: A list of strings containing the output. Raises: exceptions.CommandExecutionException -- If it detects any problem with the command. """ logger.debug('Executing commands:\n %s' % command) err_msg = 'Something happened when executing some commands on device' chan = self.ssh.get_transport().open_session() chan.settimeout(5) chan.exec_command(command) error_chan = chan.makefile_stderr() output_chan = chan.makefile() error = '' output = '' for e in error_chan.read(): error = error + self._read_wrapper(e) for o in output_chan.read(): output = output + self._read_wrapper(o) if len(error) > 0: msg = '%s %s:\n%s\n%s' % ( err_msg, self.ssh.get_host_keys().keys()[0], command, error) logger.error(msg) raise exceptions.CommandExecutionException(msg) regex = re.compile('Command fail') if len(regex.findall(output)) > 0: msg = '%s %s:\n%s\n%s' % ( err_msg, self.ssh.get_host_keys().keys()[0], command, output) logger.error(msg) raise exceptions.CommandExecutionException(msg) output = output.splitlines() # We look for the prompt and remove it i = 0 for line in output: current_line = line.split('#') if len(current_line) > 1: output[i] = current_line[1] else: output[i] = current_line[0] i += 1 return output[:-1] def load_config(self, path='', in_candidate=False, empty_candidate=False, config_text=None): """ This method will load a block of config represented as a :py:class:`FortiConfig` object in the running config, in the candidate config or in both. Args: * **path** (str) -- This is the block of config you want to load. For example *system interface*\ or *router bgp* * **in_candidate** (bool): * If ``True`` the config will be loaded as *candidate* * If ``False`` the config will be loaded as *running* * **empty_candidate** (bool): * If ``True`` the *candidate* config will be left unmodified. * If ``False`` the *candidate* config will be loaded with a block of config containing\ the same information as the config loaded in the *running* config. * **config_text** (str) -- Instead of loading the config from the device itself (using the ``path``\ variable, you can specify here the config as text. """ logger.info( 'Loading config. path:%s, in_candidate:%s, empty_candidate:%s, config_text:%s' % (path, in_candidate, empty_candidate, config_text is not None)) if config_text is None: if self.vdom is not None: if self.vdom == 'global': command = 'conf global\nshow %s\nend' % path else: command = 'conf vdom\nedit %s\nshow %s\nend' % (self.vdom, path) else: command = 'show %s' % path config_text = self.execute_command(command) if not in_candidate: self.running_config.parse_config_output(config_text) self.running_config.add_path(path) if not empty_candidate or in_candidate: self.candidate_config.parse_config_output(config_text) self.candidate_config.add_path(path) def compare_config(self, other=None, text=False): """ Compares running config with another config. This other config can be either the *running* config or a :class:`~pyFG.forticonfig.FortiConfig`. The result of the comparison will be how to reach\ the state represented in the target config (either the *candidate* or *other*) from the *running*\ config. Args: * **other** (:class:`~pyFG.forticonfig.FortiConfig`) -- This parameter, if specified, will be used for the\ comparison. If it is not specified the candidate config will be used. * **text** (bool): * If ``True`` this method will return a text diff showing how to get from the running config to\ the target config. * If ``False`` this method will return all the exact commands that needs to be run on the running\ config to reach the target config. Returns: See the explanation of the *text* arg in the section Args. """ if other is None: other = self.candidate_config if not text: return self.running_config.compare_config(other) else: diff = Differ() result = diff.compare(self.running_config.to_text().splitlines(), other.to_text().splitlines()) return '\n'.join(result) def commit(self, config_text=None, force=False): """ This method will push some config changes to the device. If the commit is successful the running config will be updated from the device and the previous config will be stored in the original config. The candidate config will not be updated. If the commit was successful it should match the running config. If it was not successful it will most certainly be different. Args: * **config_text** (string) -- If specified these are the config changes that will be applied. If you\ don't specify this parameter it will execute all necessary commands to reach the candidate_config from\ the running config. * **force(bool)**: * If ``True`` the new config will be pushed in *best effort*, errors will be ignored. * If ``False`` a rollback will be triggered if an error is detected Raises: * :class:`~pyFG.exceptions.FailedCommit` -- Something failed but we could rollback our changes * :class:`~pyFG.exceptions.ForcedCommit` -- Something failed but we avoided any rollback """ self._commit(config_text, force) def _commit(self, config_text=None, force=False, reload_original_config=True): """ This method is the same as the :py:method:`commit`: method, however, it has an extra command that will trigger the reload of the running config. The reason behind this is that in some circumstances you don´ want to reload the running config, for example, when doing a rollback. See :py:method:`commit`: for more details. """ def _execute(config_text): if config_text is None: config_text = self.compare_config() if self.vdom is None: pre = '' else: pre = 'conf global\n ' cmd = '%sexecute batch start\n' % pre cmd += config_text cmd += '\nexecute batch end\n' self.execute_command(cmd) last_log = self.execute_command('%sexecute batch lastlog' % pre) return self._parse_batch_lastlog(last_log) logger.info('Committing config ') wrong_commands = _execute(config_text) self._reload_config(reload_original_config) retry_codes = [-3, -23] retries = 5 while retries > 0: retries -= 1 for wc in wrong_commands: if int(wc[0]) in retry_codes: if config_text is None: config_text = self.compare_config() wrong_commands = _execute(config_text) self._reload_config(reload_original_config=False) break if len(wrong_commands) > 0: exit_code = -2 logging.debug('List of commands that failed: %s' % wrong_commands) if not force: exit_code = -1 self.rollback() if exit_code < 0: raise exceptions.FailedCommit(wrong_commands) def rollback(self): """ It will rollback all changes and go to the *original_config* """ logger.info('Rolling back changes') config_text = self.compare_config(other=self.original_config) if len(config_text) > 0: return self._commit(config_text, force=True, reload_original_config=False) @staticmethod def _parse_batch_lastlog(last_log): """ This static method will help reading the result of the commit, command by command. Args: last_log(list): A list containing, line by line, the result of committing the changes. Returns: A list of tuples that went wrong. The tuple will contain (*status_code*, *command*) """ regexp = re.compile('(-?[0-9]\d*):\W+(.*)') wrong_commands = list() for line in last_log: result = regexp.match(line) if result is not None: status_code = result.group(1) command = result.group(2) if int(status_code) < 0: wrong_commands.append((status_code, command)) return wrong_commands def _reload_config(self, reload_original_config): """ This command will update the running config from the live device. Args: * reload_original_config: * If ``True`` the original config will be loaded with the running config before reloading the\ original config. * If ``False`` the original config will remain untouched. """ # We don't want to reload the config under some circumstances if reload_original_config: self.original_config = self.running_config self.original_config.set_name('original') paths = self.running_config.get_paths() self.running_config = FortiConfig('running', vdom=self.vdom) for path in paths: self.load_config(path, empty_candidate=True)
def setUp(self): self.device.running_config = FortiConfig('running', vdom='test_vdom') self.device.candidate_config = FortiConfig('candidate', vdom='test_vdom')