From 0b3aa75b7f2f3c4f2556d8ca31b8e7eea484f3f1 Mon Sep 17 00:00:00 2001 From: Nathaniel Case Date: Fri, 21 Dec 2018 09:55:14 -0500 Subject: [PATCH] Add backup parameter to cli_config (#50206) * Add backup parameter to cli_config * Add a unit test for cli_config backup --- lib/ansible/modules/network/cli/cli_config.py | 22 ++++- lib/ansible/plugins/action/cli_config.py | 43 ++++++++- test/units/modules/network/cli/__init__.py | 0 test/units/modules/network/cli/cli_module.py | 88 +++++++++++++++++++ .../modules/network/cli/test_cli_config.py | 55 ++++++++++++ 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 test/units/modules/network/cli/__init__.py create mode 100644 test/units/modules/network/cli/cli_module.py create mode 100644 test/units/modules/network/cli/test_cli_config.py diff --git a/lib/ansible/modules/network/cli/cli_config.py b/lib/ansible/modules/network/cli/cli_config.py index b6e7ef36a8..5782085f6b 100644 --- a/lib/ansible/modules/network/cli/cli_config.py +++ b/lib/ansible/modules/network/cli/cli_config.py @@ -45,6 +45,17 @@ options: Use I(net_put) or I(nxos_file_copy) module to copy the flat file to remote device and then use set the fullpath to this argument. type: 'str' + backup: + description: + - This argument will cause the module to create a full backup of + the current running config from the remote device before any + changes are made. The backup file is written to the C(backup) + folder in the playbook root directory or role root directory, if + playbook is part of an ansible role. If the directory does not exist, + it is created. + type: bool + default: 'no' + version_added: "2.8" rollback: description: - The C(rollback) argument instructs the module to rollback the @@ -140,6 +151,11 @@ commands: returned: always type: list sample: ['interface Loopback999', 'no shutdown'] +backup_path: + description: The full path to the backup file + returned: when backup is yes + type: str + sample: /playbooks/ansible/backup/hostname_config.2016-07-16@22:28:34 """ import json @@ -284,6 +300,7 @@ def main(): """main entry point for execution """ argument_spec = dict( + backup=dict(default=False, type='bool'), config=dict(type='str'), commit=dict(type='bool'), replace=dict(type='str'), @@ -297,7 +314,7 @@ def main(): ) mutually_exclusive = [('config', 'rollback')] - required_one_of = [['config', 'rollback']] + required_one_of = [['backup', 'config', 'rollback']] module = AnsibleModule(argument_spec=argument_spec, mutually_exclusive=mutually_exclusive, @@ -323,6 +340,9 @@ def main(): candidate = to_text(module.params['config']) running = connection.get_config(flags=flags) + if module.params['backup']: + result['__backup__'] = running + try: result.update(run(module, capabilities, connection, candidate, running)) except Exception as exc: diff --git a/lib/ansible/plugins/action/cli_config.py b/lib/ansible/plugins/action/cli_config.py index 3041b10049..433d52ad84 100644 --- a/lib/ansible/plugins/action/cli_config.py +++ b/lib/ansible/plugins/action/cli_config.py @@ -19,13 +19,54 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +import glob +import os +import re +import time + from ansible.plugins.action.normal import ActionModule as _ActionModule +PRIVATE_KEYS_RE = re.compile('__.+__') + + class ActionModule(_ActionModule): def run(self, tmp=None, task_vars=None): if self._play_context.connection != 'network_cli': return {'failed': True, 'msg': 'Connection type %s is not valid for cli_config module' % self._play_context.connection} - return super(ActionModule, self).run(task_vars=task_vars) + result = super(ActionModule, self).run(task_vars=task_vars) + + if self._task.args.get('backup') and result.get('__backup__'): + # User requested backup and no error occurred in module. + # NOTE: If there is a parameter error, _backup key may not be in results. + filepath = self._write_backup(task_vars['inventory_hostname'], + result['__backup__']) + + result['backup_path'] = filepath + + # strip out any keys that have two leading and two trailing + # underscore characters + for key in list(result.keys()): + if PRIVATE_KEYS_RE.match(key): + del result[key] + + return result + + def _get_working_path(self): + cwd = self._loader.get_basedir() + if self._task._role is not None: + cwd = self._task._role._role_path + return cwd + + def _write_backup(self, host, contents): + backup_path = self._get_working_path() + '/backup' + if not os.path.exists(backup_path): + os.mkdir(backup_path) + for existing_backup in glob.glob('%s/%s_config.*' % (backup_path, host)): + os.remove(existing_backup) + tstamp = time.strftime("%Y-%m-%d@%H:%M:%S", time.localtime(time.time())) + filename = '%s/%s_config.%s' % (backup_path, host, tstamp) + open(filename, 'w').write(contents) + return filename diff --git a/test/units/modules/network/cli/__init__.py b/test/units/modules/network/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/units/modules/network/cli/cli_module.py b/test/units/modules/network/cli/cli_module.py new file mode 100644 index 0000000000..e92bc8ffc0 --- /dev/null +++ b/test/units/modules/network/cli/cli_module.py @@ -0,0 +1,88 @@ +# (c) 2016 Red Hat Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +import os + +from units.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase + + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as fixture: + data = fixture.read() + + try: + data = json.loads(data) + except ValueError: + pass + + fixture_data[path] = data + return data + + +class TestCliModule(ModuleTestCase): + + def execute_module(self, failed=False, changed=False, commands=None, sort=True): + + self.load_fixtures(commands) + + if failed: + result = self.failed() + self.assertTrue(result['failed'], result) + else: + result = self.changed(changed) + self.assertEqual(result['changed'], changed, result) + + if commands is not None: + if sort: + self.assertEqual(sorted(commands), sorted(result['commands']), result['commands']) + else: + self.assertEqual(commands, result['commands'], result['commands']) + + return result + + def failed(self): + with self.assertRaises(AnsibleFailJson) as exc: + self.module.main() + + result = exc.exception.args[0] + self.assertTrue(result['failed'], result) + return result + + def changed(self, changed=False): + with self.assertRaises(AnsibleExitJson) as exc: + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], changed, result) + return result + + def load_fixtures(self, commands=None): + pass diff --git a/test/units/modules/network/cli/test_cli_config.py b/test/units/modules/network/cli/test_cli_config.py new file mode 100644 index 0000000000..4fa5f27a9e --- /dev/null +++ b/test/units/modules/network/cli/test_cli_config.py @@ -0,0 +1,55 @@ +# (c) 2016 Red Hat Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from units.compat.mock import patch, MagicMock +from ansible.modules.network.cli import cli_config +from units.modules.utils import set_module_args +from .cli_module import TestCliModule, load_fixture + + +class TestCliConfigModule(TestCliModule): + + module = cli_config + + def setUp(self): + super(TestCliConfigModule, self).setUp() + + self.mock_connection = patch('ansible.modules.network.cli.cli_config.Connection') + self.get_connection = self.mock_connection.start() + + self.conn = self.get_connection() + + def tearDown(self): + super(TestCliConfigModule, self).tearDown() + + self.mock_connection.stop() + + @patch('ansible.modules.network.cli.cli_config.run') + def test_cli_config_backup_returns__backup__(self, run_mock): + self.conn.get_capabilities = MagicMock(return_value='{}') + + args = dict(backup=True) + set_module_args(args) + + run_mock.return_value = {} + + result = self.execute_module() + self.assertIn('__backup__', result)