From 4326b34a7d9f3e290ccc6bdd7350b5f7c79801cd Mon Sep 17 00:00:00 2001 From: Alex Schultz Date: Mon, 16 Dec 2019 15:21:26 -0700 Subject: [PATCH] Action plugin for timestamping a file This change adds an action plugin called timestamp_file that will check if a file exists on a remote system and copies it out of the way so that a new version can be created. This plugin can be used to ensure that logs aren't overwritten or some other file you want to keep before modifying. Change-Id: I699641f65f20c7c4814632eb380525843ebcba98 --- plugins/action/timestamp_file.py | 167 +++++++++++++++++ tests/plugins/action/__init__.py | 0 tests/plugins/action/test_timestamp_file.py | 188 ++++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 plugins/action/timestamp_file.py create mode 100644 tests/plugins/action/__init__.py create mode 100644 tests/plugins/action/test_timestamp_file.py diff --git a/plugins/action/timestamp_file.py b/plugins/action/timestamp_file.py new file mode 100644 index 0000000..c639d2a --- /dev/null +++ b/plugins/action/timestamp_file.py @@ -0,0 +1,167 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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 __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible.errors import AnsibleActionFail +from ansible.errors import AnsibleActionSkip +from ansible.module_utils.parsing.convert_bool import boolean +from ansible.plugins.action import ActionBase +from datetime import datetime + +import json +import yaml + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = """ +module: timestamp_file +author: + - "Alex Schultz (@mwhahaha)" +version_added: '2.9' +short_description: Take a copy of a file and append a timestamp +notes: [] +description: + - Take a copy of a file and append a timestamp +requirements: + - None +options: + path: + description: + - Path to file + type: str + remove: + description: + - Remove original file + default: False + type: bool + force: + description: + - Overwrite destination file if it exists + default: False + type: bool + date_format: + description: + - Timestamp format to use when appending to destination file + default: "%Y-%m-%d_%H:%M:%S" + type: str +""" +EXAMPLES = """ +- name: Snapshot a file + timestamp_file: + path: /tmp/file.log +- name: Snapshot a file and remove original + timestamp_file: + path: /tmp/file.log + remove: True +""" +RETURN = """ +dest: + description: Path to the new file + returned: if changed + type: str + sample: "/tmp/file.log.2017-07-27_16:39:00" +""" + + +class ActionModule(ActionBase): + + _VALID_ARGS = yaml.safe_load(DOCUMENTATION)['options'] + + def _get_args(self): + missing = [] + args = {} + + for option, vals in self._VALID_ARGS.items(): + if 'default' not in vals: + if self._task.args.get(option, None) is None: + missing.append(option) + continue + args[option] = self._task.args.get(option) + else: + args[option] = self._task.args.get(option, vals['default']) + + if missing: + raise AnsibleActionFail('Missing required parameters: {}'.format( + ', '.join(missing))) + return args + + def _get_date_string(self, date_format): + return datetime.now().strftime(date_format) + + def run(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = dict() + result = super(ActionModule, self).run(tmp, task_vars) + del tmp + # parse args + args = self._get_args() + + changed = False + src_path = args['path'] + + # check if source file exists + file_stat = self._execute_module( + module_name='stat', + module_args=dict(path=src_path), + task_vars=task_vars + ) + timestamp = self._get_date_string(args['date_format']) + dest_path = '.'.join([src_path, timestamp]) + if file_stat.get('stat', {}).get('exists', False) is False: + # file doesn't exist so we're done + raise AnsibleActionSkip("{} does not exist.".format(src_path)) + + # check if destination file exists + file_stat = self._execute_module( + module_name='stat', + module_args=dict(path=dest_path), + task_vars=task_vars + ) + if (not args['force'] + and file_stat.get('stat', {}).get('exists', False) is True): + raise AnsibleActionFail("Destination file {} exists. Use force " + "option to proceed.".format(dest_path)) + + # copy file out of the way + copy_result = self._execute_module( + module_name='copy', + module_args=dict(src=src_path, dest=dest_path, remote_src=True), + task_vars=task_vars + ) + if copy_result.get('failed', False): + return copy_result + changed = True + + if boolean(args.get('remove', False), strict=False): + # cleanup original file as requested + file_result = self._execute_module( + module_name='file', + module_args=dict(path=src_path, state='absent'), + task_vars=task_vars + ) + if file_result.get('failed', False): + return file_result + + result['dest'] = copy_result['dest'] + result['changed'] = changed + return result diff --git a/tests/plugins/action/__init__.py b/tests/plugins/action/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/action/test_timestamp_file.py b/tests/plugins/action/test_timestamp_file.py new file mode 100644 index 0000000..d0d3fcd --- /dev/null +++ b/tests/plugins/action/test_timestamp_file.py @@ -0,0 +1,188 @@ +# 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. + +import mock + +from ansible.errors import AnsibleActionFail +from ansible.errors import AnsibleActionSkip +from ansible.playbook.play_context import PlayContext + +from tests import base as tests_base +from plugins.action import timestamp_file + + +class TestTimestampFile(tests_base.TestCase): + + def test_run(self): + mock_task = mock.MagicMock() + mock_task.async_val = None + mock_task.action = "timestamp_file" + mock_task.args = dict(path='foo.log') + mock_connection = mock.MagicMock() + play_context = PlayContext() + + action = timestamp_file.ActionModule(mock_task, + mock_connection, + play_context, + None, + None, + None) + + mock_datetime = mock.MagicMock() + mock_datetime.return_value = 'foo' + action._get_date_string = mock_datetime + mock_execute = mock.MagicMock() + mock_execute.side_effect = [{'stat': {'exists': True}}, + {'stat': {'exists': False}}, + {'dest': 'foo.log.foo', + 'failed': False, + 'changed': True}] + + action._execute_module = mock_execute + + result = action.run() + + execute_calls = [mock.call(module_args={'path': 'foo.log'}, + module_name='stat', + task_vars={}), + mock.call(module_args={'path': 'foo.log.foo'}, + module_name='stat', + task_vars={}), + mock.call(module_args={'src': 'foo.log', + 'dest': 'foo.log.foo', + 'remote_src': True}, + module_name='copy', + task_vars={}) + ] + self.assertEqual(3, mock_execute.call_count) + mock_execute.assert_has_calls(execute_calls) + + expected_result = {'dest': 'foo.log.foo', 'changed': True} + self.assertEqual(expected_result, result) + + def test_run_source_missing_skips(self): + mock_task = mock.MagicMock() + mock_task.async_val = None + mock_task.action = "timestamp_file" + mock_task.args = dict(path='foo.log') + mock_connection = mock.MagicMock() + play_context = PlayContext() + + action = timestamp_file.ActionModule(mock_task, + mock_connection, + play_context, + None, + None, + None) + + mock_datetime = mock.MagicMock() + mock_datetime.return_value = 'foo' + action._get_date_string = mock_datetime + mock_execute = mock.MagicMock() + mock_execute.side_effect = [{'stat': {'exists': False}}] + + action._execute_module = mock_execute + + self.assertRaises(AnsibleActionSkip, action.run) + + execute_calls = [mock.call(module_args={'path': 'foo.log'}, + module_name='stat', + task_vars={}) + ] + self.assertEqual(1, mock_execute.call_count) + mock_execute.assert_has_calls(execute_calls) + + def test_run_destination_exists_fails(self): + mock_task = mock.MagicMock() + mock_task.async_val = None + mock_task.action = "timestamp_file" + mock_task.args = dict(path='foo.log') + mock_connection = mock.MagicMock() + play_context = PlayContext() + + action = timestamp_file.ActionModule(mock_task, + mock_connection, + play_context, + None, + None, + None) + + mock_datetime = mock.MagicMock() + mock_datetime.return_value = 'foo' + action._get_date_string = mock_datetime + mock_execute = mock.MagicMock() + mock_execute.side_effect = [{'stat': {'exists': True}}, + {'stat': {'exists': True}}] + + action._execute_module = mock_execute + + self.assertRaises(AnsibleActionFail, action.run) + + execute_calls = [mock.call(module_args={'path': 'foo.log'}, + module_name='stat', + task_vars={}), + mock.call(module_args={'path': 'foo.log.foo'}, + module_name='stat', + task_vars={}) + ] + self.assertEqual(2, mock_execute.call_count) + mock_execute.assert_has_calls(execute_calls) + + def test_run_destination_exists_force(self): + mock_task = mock.MagicMock() + mock_task.async_val = None + mock_task.action = "timestamp_file" + mock_task.args = dict(path='foo.log', force=True) + mock_connection = mock.MagicMock() + play_context = PlayContext() + + action = timestamp_file.ActionModule(mock_task, + mock_connection, + play_context, + None, + None, + None) + + mock_datetime = mock.MagicMock() + mock_datetime.return_value = 'foo' + action._get_date_string = mock_datetime + mock_execute = mock.MagicMock() + mock_execute.side_effect = [{'stat': {'exists': True}}, + {'stat': {'exists': True}}, + {'dest': 'foo.log.foo', + 'failed': False, + 'changed': True}] + + action._execute_module = mock_execute + + result = action.run() + + execute_calls = [mock.call(module_args={'path': 'foo.log'}, + module_name='stat', + task_vars={}), + mock.call(module_args={'path': 'foo.log.foo'}, + module_name='stat', + task_vars={}), + mock.call(module_args={'src': 'foo.log', + 'dest': 'foo.log.foo', + 'remote_src': True}, + module_name='copy', + task_vars={}) + ] + self.assertEqual(3, mock_execute.call_count) + mock_execute.assert_has_calls(execute_calls) + + expected_result = {'dest': 'foo.log.foo', 'changed': True} + self.assertEqual(expected_result, result)