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
This commit is contained in:
167
plugins/action/timestamp_file.py
Normal file
167
plugins/action/timestamp_file.py
Normal file
@@ -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
|
||||||
0
tests/plugins/action/__init__.py
Normal file
0
tests/plugins/action/__init__.py
Normal file
188
tests/plugins/action/test_timestamp_file.py
Normal file
188
tests/plugins/action/test_timestamp_file.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user