diff --git a/salt/engines/script.py b/salt/engines/script.py new file mode 100644 index 000000000000..307099be3ec6 --- /dev/null +++ b/salt/engines/script.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +''' +Send events based on a script's stdout + +Example Config + +.. code-block:: yaml + + engines: + - script: + cmd: /some/script.py -a 1 -b 2 + output: json + interval: 5 + +Script engine configs: + + cmd: Script or command to execute + output: Any available saltstack deserializer + interval: How often in seconds to execute the command + +''' + +from __future__ import absolute_import, print_function +import logging +import shlex +import time +import subprocess + +# import salt libs +import salt.utils.event +import salt.utils.process +import salt.loader +from salt.exceptions import CommandExecutionError + +from salt.ext import six + + +log = logging.getLogger(__name__) + + +def _read_stdout(proc): + ''' + Generator that returns stdout + ''' + for line in iter(proc.stdout.readline, ""): + yield line + + +def _get_serializer(output): + ''' + Helper to return known serializer based on + pass output argument + ''' + serializers = salt.loader.serializers(__opts__) + try: + return getattr(serializers, output) + except AttributeError: + raise CommandExecutionError("Unknown serializer '%s' found for output option", output) + + +def start(cmd, output='json', interval=1): + ''' + Parse stdout of a command and generate an event + + The script engine will scrap stdout of the + given script and generate an event based on the + presence of the 'tag' key and it's value. + + If there is a data obj available, that will also + be fired along with the tag. + + Example: + + Given the following json output from a script: + + { "tag" : "lots/of/tacos", + "data" : { "toppings" : "cilantro" } + } + + This will fire the event 'lots/of/tacos' + on the event bus with the data obj as is. + + :param cmd: The command to execute + :param output: How to deserialize stdout of the script + :param interval: How often to execute the script. + ''' + try: + cmd = shlex.split(cmd) + except AttributeError: + cmd = shlex.split(six.text_type(cmd)) + log.debug("script engine using command %s", cmd) + + serializer = _get_serializer(output) + + if __opts__.get('__role') == 'master': + fire_master = salt.utils.event.get_master_event( + __opts__, + __opts__['sock_dir']).fire_event + else: + fire_master = __salt__['event.send'] + + while True: + + try: + proc = subprocess.Popen(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + + log.debug("Starting script with pid %d", proc.pid) + + for raw_event in _read_stdout(proc): + log.debug(raw_event) + + event = serializer.deserialize(raw_event) + tag = event.get('tag', None) + data = event.get('data', {}) + + if data and 'id' not in data: + data['id'] = __opts__['id'] + + if tag: + log.info("script engine firing event with tag %s", tag) + fire_master(tag=tag, data=data) + + log.debug("Closing script with pid %d", proc.pid) + proc.stdout.close() + rc = proc.wait() + if rc: + raise subprocess.CalledProcessError(rc, cmd) + + except subprocess.CalledProcessError as e: + log.error(e) + finally: + if proc.poll is None: + proc.terminate() + + time.sleep(interval) diff --git a/tests/unit/engines/test_script.py b/tests/unit/engines/test_script.py new file mode 100644 index 000000000000..8ddd712043cb --- /dev/null +++ b/tests/unit/engines/test_script.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +''' +unit tests for the script engine +''' +# Import Python libs +from __future__ import absolute_import, print_function, unicode_literals + +# Import Salt Testing Libs +from tests.support.mixins import LoaderModuleMockMixin +from tests.support.unit import skipIf, TestCase +from tests.support.mock import ( + NO_MOCK, + NO_MOCK_REASON, + patch) + +# Import Salt Libs +import salt.config +import salt.engines.script as script +from salt.exceptions import CommandExecutionError + + +@skipIf(NO_MOCK, NO_MOCK_REASON) +class EngineScriptTestCase(TestCase, LoaderModuleMockMixin): + ''' + Test cases for salt.engine.script + ''' + + def setup_loader_modules(self): + + opts = salt.config.DEFAULT_MASTER_OPTS + return { + script: { + '__opts__': opts + } + } + + def test__get_serializer(self): + ''' + Test known serializer is returned or exception is raised + if unknown serializer + ''' + for serializers in ('json', 'yaml', 'msgpack'): + self.assertTrue(script._get_serializer(serializers)) + + with self.assertRaises(CommandExecutionError): + script._get_serializer('bad') + + def test__read_stdout(self): + ''' + Test we can yield stdout + ''' + with patch('subprocess.Popen') as popen_mock: + popen_mock.stdout.readline.return_value = 'test' + self.assertEqual(next(script._read_stdout(popen_mock)), 'test')