diff --git a/doc/source/modules/modules-tripleo_diff_exec.rst b/doc/source/modules/modules-tripleo_diff_exec.rst new file mode 100644 index 000000000..04eef8cdc --- /dev/null +++ b/doc/source/modules/modules-tripleo_diff_exec.rst @@ -0,0 +1,14 @@ +========================== +Module - tripleo_diff_exec +========================== + + +This module provides for the following ansible plugin: + + * tripleo_diff_exec + + +.. ansibleautoplugin:: + :module: tripleo_ansible/ansible_plugins/modules/tripleo_diff_exec.py + :documentation: true + :examples: true diff --git a/tripleo_ansible/ansible_plugins/modules/tripleo_diff_exec.py b/tripleo_ansible/ansible_plugins/modules/tripleo_diff_exec.py new file mode 100644 index 000000000..b0a1f43c6 --- /dev/null +++ b/tripleo_ansible/ansible_plugins/modules/tripleo_diff_exec.py @@ -0,0 +1,144 @@ +#!/usr/bin/python +# Copyright 2020 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +__metaclass__ = type + +from ansible.module_utils.basic import AnsibleModule + +import filecmp +import os +import shutil +import subprocess +import traceback +import yaml + + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + + +DOCUMENTATION = ''' +--- +module: tripleo_diff_exec +short_description: Run a command if a file is different than a previous one +version_added: "2.9" +author: "Alex Schultz (@mwhahaha)" +description: + - Takes a file path and compares it to a previous version (created by this + module) and runs a command if the contents are different. +options: + command: + description: + - Command to run if the state file has changed since the last run. If the + previous version of the state file does not exist, the command is run. + required: true + type: str + environment: + description: + - Environment variables to be passed to the command being run + required: false + type: dict + default: {} + return_codes: + description: + - List of valid return code values for the command + required: false + type: list + default: [0] + state_file: + description: + - File to use to compare to the previous version + required: true + type: str + state_file_suffix: + description: + - Suffix to use to store the previous version of the file for comparisons + between runs + required: false + default: -tripleo_diff_exec + type: str +''' + +RETURN = ''' +''' + +EXAMPLES = ''' +- name: Run command if file is changed + tripleo_diff_exec: + command: systemctl restart foo.service + state_file: /var/lib/my-file + state_file_suffix: -foo + environment: + FOO: bar +''' + + +def run(module): + results = dict( + changed=False + ) + + args = module.params + command = args.get('command') + environment = args.get('environment', {}) + return_codes = args.get('return_codes', [0]) + state_file = args.get('state_file') + state_file_bkup = args.get('state_file') + args.get('state_file_suffix', + '-tripleo_diff_exec') + + if not os.path.exists(state_file): + results['failed'] = True + results['error'] = "Missing state file" + results['msg'] = "State file does not exist: %s" % state_file + elif (not os.path.exists(state_file_bkup) + or not filecmp.cmp(state_file, state_file_bkup, shallow=False)): + # run command + try: + tmp_environment = os.environ.copy() + tmp_environment.update(environment) + r = subprocess.run(command, shell=True, env=tmp_environment, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True) + if r.returncode in return_codes: + results['changed'] = True + # copy old to bkup + shutil.copy2(state_file, state_file_bkup) + else: + results['failed'] = True + results['error'] = "Failed running command" + results['msg'] = ("Error running %s. rc: %s, stdout: %s, " + "stderr: %s" % (command, r.returncode, + r.stdout, r.stderr)) + except Exception as e: + results['failed'] = True + results['error'] = traceback.format_exc() + results['msg'] = "Unhandled exception: %s" % e + + module.exit_json(**results) + + +def main(): + module = AnsibleModule( + argument_spec=yaml.safe_load(DOCUMENTATION)['options'], + supports_check_mode=False, + ) + run(module) + + +if __name__ == '__main__': + main() diff --git a/tripleo_ansible/tests/modules/test_tripleo_diff_exec.py b/tripleo_ansible/tests/modules/test_tripleo_diff_exec.py new file mode 100644 index 000000000..2a89985d0 --- /dev/null +++ b/tripleo_ansible/tests/modules/test_tripleo_diff_exec.py @@ -0,0 +1,169 @@ +# Copyright 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from tripleo_ansible.ansible_plugins.modules import tripleo_diff_exec +from tripleo_ansible.tests import base as tests_base + +import mock + + +class TestTripleoDiffExec(tests_base.TestCase): + @mock.patch.dict('os.environ', dict(), clear=True) + @mock.patch('shutil.copy2') + @mock.patch('subprocess.run') + @mock.patch('filecmp.cmp') + @mock.patch('os.path.exists') + def test_first_run(self, mock_exists, mock_cmp, mock_run, mock_copy2): + mock_module = mock.MagicMock() + mock_module.params = { + 'command': 'foo', + 'return_codes': [0], + 'environment': {'foo': 'bar'}, + 'state_file': '/tmp/foo', + 'state_file_suffix': '-previous' + } + mock_exists.side_effect = [True, False] + mock_exit = mock.MagicMock() + mock_module.exit_json = mock_exit + mock_return = mock.MagicMock() + mock_return.returncode = 0 + mock_run.return_value = mock_return + tripleo_diff_exec.run(mock_module) + mock_exit.assert_called_once_with(changed=True) + mock_run.assert_called_once_with( + 'foo', shell=True, env={'foo': 'bar'}, stderr=-1, stdout=-1, + universal_newlines=True) + mock_copy2.assert_called_with('/tmp/foo', '/tmp/foo-previous') + + @mock.patch.dict('os.environ', dict(), clear=True) + @mock.patch('shutil.copy2') + @mock.patch('subprocess.run') + @mock.patch('filecmp.cmp') + @mock.patch('os.path.exists') + def test_no_change(self, mock_exists, mock_cmp, mock_run, mock_copy2): + mock_module = mock.MagicMock() + mock_module.params = { + 'command': 'foo', + 'return_codes': [0], + 'state_file': '/tmp/foo', + 'state_file_suffix': '-previous' + } + mock_exists.return_value = True + mock_cmp.return_value = True + mock_exit = mock.MagicMock() + mock_module.exit_json = mock_exit + tripleo_diff_exec.run(mock_module) + self.assertEqual(mock_run.call_count, 0) + self.assertEqual(mock_copy2.call_count, 0) + mock_exit.assert_called_once_with(changed=False) + + @mock.patch.dict('os.environ', dict(), clear=True) + @mock.patch('shutil.copy2') + @mock.patch('subprocess.run') + @mock.patch('filecmp.cmp') + @mock.patch('os.path.exists') + def test_file_changed(self, mock_exists, mock_cmp, mock_run, mock_copy2): + mock_module = mock.MagicMock() + mock_module.params = { + 'command': 'foo', + 'return_codes': [0], + 'state_file': '/tmp/foo', + 'state_file_suffix': '-previous' + } + mock_exists.return_value = True + mock_cmp.return_value = False + mock_exit = mock.MagicMock() + mock_module.exit_json = mock_exit + mock_return = mock.MagicMock() + mock_return.returncode = 0 + mock_run.return_value = mock_return + tripleo_diff_exec.run(mock_module) + mock_run.assert_called_once_with( + 'foo', shell=True, env={}, stderr=-1, stdout=-1, + universal_newlines=True) + mock_copy2.assert_called_with('/tmp/foo', '/tmp/foo-previous') + mock_exit.assert_called_once_with(changed=True) + + @mock.patch.dict('os.environ', dict(), clear=True) + @mock.patch('shutil.copy2') + @mock.patch('subprocess.run') + @mock.patch('filecmp.cmp') + @mock.patch('os.path.exists') + def test_missing_state(self, mock_exists, mock_cmp, mock_run, mock_copy2): + mock_module = mock.MagicMock() + mock_module.params = { + 'command': 'foo', + 'return_codes': [0], + 'state_file': '/tmp/foo', + 'state_file_suffix': '-previous' + } + mock_exists.return_value = False + mock_exit = mock.MagicMock() + mock_module.exit_json = mock_exit + tripleo_diff_exec.run(mock_module) + mock_exit.assert_called_once_with(changed=False, + error='Missing state file', + failed=True, + msg=('State file does not exist: ' + '/tmp/foo')) + + @mock.patch.dict('os.environ', dict(), clear=True) + @mock.patch('shutil.copy2') + @mock.patch('subprocess.run') + @mock.patch('filecmp.cmp') + @mock.patch('os.path.exists') + def test_exec_exception(self, mock_exists, mock_cmp, mock_run, mock_copy2): + mock_module = mock.MagicMock() + mock_module.params = { + 'command': 'foo', + 'return_codes': [0], + 'state_file': '/tmp/foo', + 'state_file_suffix': '-previous' + } + mock_exists.side_effect = [True, False] + mock_exit = mock.MagicMock() + mock_module.exit_json = mock_exit + mock_run.side_effect = Exception('meh') + tripleo_diff_exec.run(mock_module) + mock_exit.assert_called_once_with(changed=False, + error=mock.ANY, + failed=True, + msg='Unhandled exception: meh') + + @mock.patch.dict('os.environ', dict(), clear=True) + @mock.patch('shutil.copy2') + @mock.patch('subprocess.run') + @mock.patch('filecmp.cmp') + @mock.patch('os.path.exists') + def test_exec_failed(self, mock_exists, mock_cmp, mock_run, mock_copy2): + mock_module = mock.MagicMock() + mock_module.params = { + 'command': 'foo', + 'return_codes': [0], + 'state_file': '/tmp/foo', + 'state_file_suffix': '-previous' + } + mock_exists.side_effect = [True, False] + mock_exit = mock.MagicMock() + mock_module.exit_json = mock_exit + mock_return = mock.MagicMock() + mock_return.returncode = 1 + mock_return.stdout = 'out' + mock_return.stderr = 'err' + mock_run.return_value = mock_return + tripleo_diff_exec.run(mock_module) + mock_exit.assert_called_once_with( + changed=False, error='Failed running command', failed=True, + msg=('Error running foo. rc: 1, stdout: out, stderr: err'))