diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fffac28615..ff3f22e160 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,16 @@ in development Added ~~~~~ +* Add new runners: ``winrm-cmd``, ``winrm-ps-cmd`` and ``winrm-ps-script``. + The ``winrm-cmd`` runner executes Command Prompt commands remotely on Windows hosts using the + WinRM protocol. The ``winrm-ps-cmd`` and ``winrm-ps-script`` runners execute PowerShell commands + and scripts on remote Windows hosts using the WinRM protocol. + + To accompany these new runners, there are two new actions ``core.winrm_cmd`` that executes remote + Command Prompt commands along with ``core.winrm_ps_cmd`` that executes remote PowerShell commands. + (new feature) #1636 + + Contributed by Nick Maludy (Encore Technologies). * Add new ``?tags``, query param filter to the ``/v1/actions`` API endpoint. This query parameter allows users to filter out actions based on the tag name . By default, when no filter values are provided, all actions are returned. (new feature) #4219 @@ -17,6 +27,11 @@ Changed * Update ``st2client/setup.py`` file to dynamically load requirements from ``st2client/requirements.txt`` file. The code works with pip >= 6.0.0, although using pip 9.0.0 or higher is strongly recommended. (improvement) #4209 +* Migrated runners to using the ``in-requirements.txt`` pattern for "components" in the build + system, so the ``Makefile`` correctly generates and installs runner dependencies during + testing and packaging. (improvement) (bugfix) #4169 + + Contributed by Nick Maludy (Encore Technologies). 2.8.0 - July 10, 2018 --------------------- diff --git a/Makefile b/Makefile index 6226f4e3f2..0227bab172 100644 --- a/Makefile +++ b/Makefile @@ -316,10 +316,10 @@ requirements: virtualenv .sdist-requirements $(VIRTUALENV_DIR)/bin/pip install --upgrade "virtualenv==15.1.0" # Required for packs.install in dev envs. # Generate all requirements to support current CI pipeline. - $(VIRTUALENV_DIR)/bin/python scripts/fixate-requirements.py --skip=virtualenv -s st2*/in-requirements.txt -f fixed-requirements.txt -o requirements.txt + $(VIRTUALENV_DIR)/bin/python scripts/fixate-requirements.py --skip=virtualenv -s st2*/in-requirements.txt contrib/runners/*/in-requirements.txt -f fixed-requirements.txt -o requirements.txt # Generate finall requirements.txt file for each component - @for component in $(COMPONENTS); do\ + @for component in $(COMPONENTS_WITH_RUNNERS); do\ echo "==========================================================="; \ echo "Generating requirements.txt for" $$component; \ echo "==========================================================="; \ diff --git a/contrib/core/actions/winrm_cmd.yaml b/contrib/core/actions/winrm_cmd.yaml new file mode 100644 index 0000000000..11cf2c6ee5 --- /dev/null +++ b/contrib/core/actions/winrm_cmd.yaml @@ -0,0 +1,12 @@ + +--- + name: "winrm_cmd" + runner_type: "winrm-cmd" + description: "Action to execute arbitrary Windows Command Prompt command remotely via WinRM." + enabled: true + entry_point: "" + parameters: + cmd: + description: "Arbitrary Windows Command Prompt command to be executed on the remote host." + type: "string" + required: true diff --git a/contrib/core/actions/winrm_ps_cmd.yaml b/contrib/core/actions/winrm_ps_cmd.yaml new file mode 100644 index 0000000000..8415aec1fe --- /dev/null +++ b/contrib/core/actions/winrm_ps_cmd.yaml @@ -0,0 +1,12 @@ + +--- + name: "winrm_ps_cmd" + runner_type: "winrm-ps-cmd" + description: "Action to execute arbitrary Windows PowerShell command remotely via WinRM." + enabled: true + entry_point: "" + parameters: + cmd: + description: "Arbitrary Windows PowerShell command to be executed on the remote host." + type: "string" + required: true diff --git a/contrib/examples/actions/winrm_get_uptime.yaml b/contrib/examples/actions/winrm_get_uptime.yaml new file mode 100644 index 0000000000..412e0f8ea2 --- /dev/null +++ b/contrib/examples/actions/winrm_get_uptime.yaml @@ -0,0 +1,6 @@ +--- +name: "winrm_get_uptime" +description: "Action that returns Windows host's uptime using WinRM." +enabled: true +entry_point: "windows/get_uptime.ps1" +runner_type: "winrm-ps-script" diff --git a/contrib/examples/actions/winrm_ipconfig.yaml b/contrib/examples/actions/winrm_ipconfig.yaml new file mode 100644 index 0000000000..75410dd7d3 --- /dev/null +++ b/contrib/examples/actions/winrm_ipconfig.yaml @@ -0,0 +1,10 @@ +--- +name: "winrm_get_ipconfig" +description: "Action that returns Windows host's IP config information." +enabled: true +runner_type: "winrm-cmd" +parameters: + cmd: + type: string + default: "ipconfig /all" + immutable: true diff --git a/contrib/examples/actions/winrm_powershell_env.yaml b/contrib/examples/actions/winrm_powershell_env.yaml new file mode 100644 index 0000000000..5a63458e5a --- /dev/null +++ b/contrib/examples/actions/winrm_powershell_env.yaml @@ -0,0 +1,17 @@ +--- +name: "winrm_powershell_env" +description: "Action that sets an environment variable then prints it out. This also demonstrates returning JSON data from PowerShell and being able to utilize it as an object via stdout." +enabled: true +runner_type: "winrm-ps-cmd" +parameters: + env_var_name: + type: string + default: "ST2_WINRM_ENV_TEST" + cmd: + type: string + default: "Get-Item Env:{{ env_var_name }} | ConvertTo-Json -Depth 1 -Compress" + immutable: true + env: + type: object + default: + "{{ env_var_name }}": "This is a test" diff --git a/contrib/runners/action_chain_runner/in-requirements.txt b/contrib/runners/action_chain_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/action_chain_runner/requirements.txt b/contrib/runners/action_chain_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/action_chain_runner/requirements.txt +++ b/contrib/runners/action_chain_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/announcement_runner/in-requirements.txt b/contrib/runners/announcement_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/announcement_runner/requirements.txt b/contrib/runners/announcement_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/announcement_runner/requirements.txt +++ b/contrib/runners/announcement_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/cloudslang_runner/in-requirements.txt b/contrib/runners/cloudslang_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/cloudslang_runner/requirements.txt b/contrib/runners/cloudslang_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/cloudslang_runner/requirements.txt +++ b/contrib/runners/cloudslang_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/http_runner/in-requirements.txt b/contrib/runners/http_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/http_runner/requirements.txt b/contrib/runners/http_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/http_runner/requirements.txt +++ b/contrib/runners/http_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/inquirer_runner/in-requirements.txt b/contrib/runners/inquirer_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/inquirer_runner/requirements.txt b/contrib/runners/inquirer_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/inquirer_runner/requirements.txt +++ b/contrib/runners/inquirer_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/local_runner/in-requirements.txt b/contrib/runners/local_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/local_runner/requirements.txt b/contrib/runners/local_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/local_runner/requirements.txt +++ b/contrib/runners/local_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/mistral_v2/in-requirements.txt b/contrib/runners/mistral_v2/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/mistral_v2/requirements.txt b/contrib/runners/mistral_v2/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/mistral_v2/requirements.txt +++ b/contrib/runners/mistral_v2/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/noop_runner/in-requirements.txt b/contrib/runners/noop_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/noop_runner/requirements.txt b/contrib/runners/noop_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/noop_runner/requirements.txt +++ b/contrib/runners/noop_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/python_runner/in-requirements.txt b/contrib/runners/python_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/python_runner/requirements.txt b/contrib/runners/python_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/python_runner/requirements.txt +++ b/contrib/runners/python_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/remote_runner/in-requirements.txt b/contrib/runners/remote_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/remote_runner/requirements.txt b/contrib/runners/remote_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/remote_runner/requirements.txt +++ b/contrib/runners/remote_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/windows_runner/in-requirements.txt b/contrib/runners/windows_runner/in-requirements.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/windows_runner/requirements.txt b/contrib/runners/windows_runner/requirements.txt index e69de29bb2..b4be240ac2 100644 --- a/contrib/runners/windows_runner/requirements.txt +++ b/contrib/runners/windows_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! + diff --git a/contrib/runners/winrm_runner/MANIFEST.in b/contrib/runners/winrm_runner/MANIFEST.in new file mode 100644 index 0000000000..25ce80c091 --- /dev/null +++ b/contrib/runners/winrm_runner/MANIFEST.in @@ -0,0 +1,11 @@ +# https://docs.python.org/2/distutils/sourcedist.html#commands +# Include all files under the source tree by default. +# Another behaviour can be used in the future though. +include __init__.py +include runner.yaml +include dist_utils.py +include requirements.txt +include README.rst +include CHANGELOG.rst +include LICENSE +global-exclude *.pyc diff --git a/contrib/runners/winrm_runner/dist_utils.py b/contrib/runners/winrm_runner/dist_utils.py new file mode 100644 index 0000000000..ede9e4a358 --- /dev/null +++ b/contrib/runners/winrm_runner/dist_utils.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 +import os +import re +import sys + +from distutils.version import StrictVersion + +GET_PIP = 'curl https://bootstrap.pypa.io/get-pip.py | python' + +try: + import pip + from pip import __version__ as pip_version +except ImportError as e: + print('Failed to import pip: %s' % (str(e))) + print('') + print('Download pip:\n%s' % (GET_PIP)) + sys.exit(1) + +try: + # pip < 10.0 + from pip.req import parse_requirements +except ImportError: + # pip >= 10.0 + + try: + from pip._internal.req.req_file import parse_requirements + except ImportError as e: + print('Failed to import parse_requirements from pip: %s' % (str(e))) + print('Using pip: %s' % (str(pip_version))) + sys.exit(1) + +__all__ = [ + 'check_pip_version', + 'fetch_requirements', + 'apply_vagrant_workaround', + 'get_version_string', + 'parse_version_string' +] + + +def check_pip_version(): + """ + Ensure that a minimum supported version of pip is installed. + """ + if StrictVersion(pip.__version__) < StrictVersion('6.0.0'): + print("Upgrade pip, your version `{0}' " + "is outdated:\n{1}".format(pip.__version__, GET_PIP)) + sys.exit(1) + + +def fetch_requirements(requirements_file_path): + """ + Return a list of requirements and links by parsing the provided requirements file. + """ + links = [] + reqs = [] + for req in parse_requirements(requirements_file_path, session=False): + if req.link: + links.append(str(req.link)) + reqs.append(str(req.req)) + return (reqs, links) + + +def apply_vagrant_workaround(): + """ + Function which detects if the script is being executed inside vagrant and if it is, it deletes + "os.link" attribute. + Note: Without this workaround, setup.py sdist will fail when running inside a shared directory + (nfs / virtualbox shared folders). + """ + if os.environ.get('USER', None) == 'vagrant': + del os.link + + +def get_version_string(init_file): + """ + Read __version__ string for an init file. + """ + + with open(init_file, 'r') as fp: + content = fp.read() + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", + content, re.M) + if version_match: + return version_match.group(1) + + raise RuntimeError('Unable to find version string in %s.' % (init_file)) + + +# alias for get_version_string +parse_version_string = get_version_string diff --git a/contrib/runners/winrm_runner/in-requirements.txt b/contrib/runners/winrm_runner/in-requirements.txt new file mode 100644 index 0000000000..17a16ff26d --- /dev/null +++ b/contrib/runners/winrm_runner/in-requirements.txt @@ -0,0 +1 @@ +pywinrm diff --git a/contrib/runners/winrm_runner/requirements.txt b/contrib/runners/winrm_runner/requirements.txt new file mode 100644 index 0000000000..8fa65d3a82 --- /dev/null +++ b/contrib/runners/winrm_runner/requirements.txt @@ -0,0 +1,2 @@ +# Don't edit this file. It's generated automatically! +pywinrm==0.3.0 diff --git a/contrib/runners/winrm_runner/runner.yaml b/contrib/runners/winrm_runner/runner.yaml new file mode 120000 index 0000000000..45f4fed513 --- /dev/null +++ b/contrib/runners/winrm_runner/runner.yaml @@ -0,0 +1 @@ +./winrm_runner/runner.yaml \ No newline at end of file diff --git a/contrib/runners/winrm_runner/setup.py b/contrib/runners/winrm_runner/setup.py new file mode 100644 index 0000000000..67b27dad10 --- /dev/null +++ b/contrib/runners/winrm_runner/setup.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 +import os.path + +from setuptools import setup +from setuptools import find_packages + +from dist_utils import fetch_requirements +from dist_utils import apply_vagrant_workaround + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +REQUIREMENTS_FILE = os.path.join(BASE_DIR, 'requirements.txt') + +install_reqs, dep_links = fetch_requirements(REQUIREMENTS_FILE) + +apply_vagrant_workaround() +setup( + name='stackstorm-runner-winrm', + version='2.8.0', + description=('WinRM shell command and PowerShell script action runner for' + ' the StackStorm event-driven automation platform'), + author='StackStorm', + author_email='info@stackstorm.com', + license='Apache License (2.0)', + url='https://stackstorm.com/', + install_requires=install_reqs, + dependency_links=dep_links, + test_suite='tests', + zip_safe=False, + include_package_data=True, + packages=find_packages(exclude=['setuptools', 'tests']), + package_data={'winrm_ps_command_runner': ['runner.yaml']}, + scripts=[], + entry_points={ + 'st2common.runners.runner': [ + 'winrm-cmd = winrm_runner.winrm_command_runner', + 'winrm-ps-cmd = winrm_runner.winrm_ps_command_runner', + 'winrm-ps-script = winrm_runner.winrm_ps_script_runner', + ], + } +) diff --git a/contrib/runners/winrm_runner/tests/integration/__init__.py b/contrib/runners/winrm_runner/tests/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/winrm_runner/tests/unit/__init__.py b/contrib/runners/winrm_runner/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/winrm_runner/tests/unit/fixtures/TestScript.ps1 b/contrib/runners/winrm_runner/tests/unit/fixtures/TestScript.ps1 new file mode 100644 index 0000000000..ed8bc4bad8 --- /dev/null +++ b/contrib/runners/winrm_runner/tests/unit/fixtures/TestScript.ps1 @@ -0,0 +1,23 @@ +[CmdletBinding()] +Param( + [bool]$p_bool, + [int]$p_integer, + [double]$p_number, + [string]$p_str, + [array]$p_array, + [hashtable]$p_obj, + [Parameter(Position=0)] + [string]$p_pos0, + [Parameter(Position=1)] + [string]$p_pos1 +) + + +Write-Output "p_bool = $p_bool" +Write-Output "p_integer = $p_integer" +Write-Output "p_number = $p_number" +Write-Output "p_str = $p_str" +Write-Output "p_array = $($p_array | ConvertTo-Json -Compress)" +Write-Output "p_obj = $($p_obj | ConvertTo-Json -Compress)" +Write-Output "p_pos0 = $p_pos0" +Write-Output "p_pos1 = $p_pos1" diff --git a/contrib/runners/winrm_runner/tests/unit/test_winrm_base.py b/contrib/runners/winrm_runner/tests/unit/test_winrm_base.py new file mode 100644 index 0000000000..1ef787ac5f --- /dev/null +++ b/contrib/runners/winrm_runner/tests/unit/test_winrm_base.py @@ -0,0 +1,695 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 + +import collections +import time +import mock +from base64 import b64encode +from winrm import Response +from winrm.exceptions import WinRMOperationTimeoutError + +from st2common.runners.base import ActionRunner +from st2tests.base import RunnerTestCase +from winrm_runner.winrm_base import WinRmBaseRunner, WinRmRunnerTimoutError +from winrm_runner.winrm_base import WINRM_TIMEOUT_EXIT_CODE +from winrm_runner.winrm_base import PS_ESCAPE_SEQUENCES +from winrm_runner import winrm_ps_command_runner + + +class WinRmBaseTestCase(RunnerTestCase): + + def setUp(self): + super(WinRmBaseTestCase, self).setUpClass() + self._runner = winrm_ps_command_runner.get_runner() + + def _init_runner(self): + runner_parameters = {'host': 'host@domain.tld', + 'username': 'user@domain.tld', + 'password': 'xyz987'} + self._runner.runner_parameters = runner_parameters + self._runner.pre_run() + + def test_win_rm_runner_timout_error(self): + error = WinRmRunnerTimoutError('test_response') + self.assertIsInstance(error, Exception) + self.assertEquals(error.response, 'test_response') + with self.assertRaises(WinRmRunnerTimoutError): + raise WinRmRunnerTimoutError('test raising') + + def test_init(self): + runner = winrm_ps_command_runner.WinRmPsCommandRunner('abcdef') + self.assertIsInstance(runner, WinRmBaseRunner) + self.assertIsInstance(runner, ActionRunner) + self.assertEquals(runner.runner_id, "abcdef") + + @mock.patch('winrm_runner.winrm_base.ActionRunner.pre_run') + def test_pre_run(self, mock_pre_run): + runner_parameters = {'host': 'host@domain.tld', + 'username': 'user@domain.tld', + 'password': 'abc123', + 'timeout': 99, + 'port': 1234, + 'scheme': 'http', + 'transport': 'ntlm', + 'verify_ssl_cert': False, + 'cwd': 'C:\\Test', + 'env': {'TEST_VAR': 'TEST_VALUE'}, + 'kwarg_op': '/'} + self._runner.runner_parameters = runner_parameters + self._runner.pre_run() + mock_pre_run.assert_called_with() + self.assertEquals(self._runner._host, 'host@domain.tld') + self.assertEquals(self._runner._username, 'user@domain.tld') + self.assertEquals(self._runner._password, 'abc123') + self.assertEquals(self._runner._timeout, 99) + self.assertEquals(self._runner._read_timeout, 100) + self.assertEquals(self._runner._port, 1234) + self.assertEquals(self._runner._scheme, 'http') + self.assertEquals(self._runner._transport, 'ntlm') + self.assertEquals(self._runner._winrm_url, 'http://host@domain.tld:1234/wsman') + self.assertEquals(self._runner._verify_ssl, False) + self.assertEquals(self._runner._server_cert_validation, 'ignore') + self.assertEquals(self._runner._cwd, 'C:\\Test') + self.assertEquals(self._runner._env, {'TEST_VAR': 'TEST_VALUE'}) + self.assertEquals(self._runner._kwarg_op, '/') + + @mock.patch('winrm_runner.winrm_base.ActionRunner.pre_run') + def test_pre_run_defaults(self, mock_pre_run): + runner_parameters = {'host': 'host@domain.tld', + 'username': 'user@domain.tld', + 'password': 'abc123'} + self._runner.runner_parameters = runner_parameters + self._runner.pre_run() + mock_pre_run.assert_called_with() + self.assertEquals(self._runner._host, 'host@domain.tld') + self.assertEquals(self._runner._username, 'user@domain.tld') + self.assertEquals(self._runner._password, 'abc123') + self.assertEquals(self._runner._timeout, 60) + self.assertEquals(self._runner._read_timeout, 61) + self.assertEquals(self._runner._port, 5986) + self.assertEquals(self._runner._scheme, 'https') + self.assertEquals(self._runner._transport, 'ntlm') + self.assertEquals(self._runner._winrm_url, 'https://host@domain.tld:5986/wsman') + self.assertEquals(self._runner._verify_ssl, True) + self.assertEquals(self._runner._server_cert_validation, 'validate') + self.assertEquals(self._runner._cwd, None) + self.assertEquals(self._runner._env, {}) + self.assertEquals(self._runner._kwarg_op, '-') + + @mock.patch('winrm_runner.winrm_base.ActionRunner.pre_run') + def test_pre_run_5985_force_http(self, mock_pre_run): + runner_parameters = {'host': 'host@domain.tld', + 'username': 'user@domain.tld', + 'password': 'abc123', + 'port': 5985, + 'scheme': 'https'} + self._runner.runner_parameters = runner_parameters + self._runner.pre_run() + mock_pre_run.assert_called_with() + self.assertEquals(self._runner._host, 'host@domain.tld') + self.assertEquals(self._runner._username, 'user@domain.tld') + self.assertEquals(self._runner._password, 'abc123') + self.assertEquals(self._runner._timeout, 60) + self.assertEquals(self._runner._read_timeout, 61) + # ensure port is still 5985 + self.assertEquals(self._runner._port, 5985) + # ensure scheme is set back to http + self.assertEquals(self._runner._scheme, 'http') + self.assertEquals(self._runner._transport, 'ntlm') + self.assertEquals(self._runner._winrm_url, 'http://host@domain.tld:5985/wsman') + self.assertEquals(self._runner._verify_ssl, True) + self.assertEquals(self._runner._server_cert_validation, 'validate') + self.assertEquals(self._runner._cwd, None) + self.assertEquals(self._runner._env, {}) + self.assertEquals(self._runner._kwarg_op, '-') + + @mock.patch('winrm_runner.winrm_base.ActionRunner.pre_run') + def test_pre_run_none_env(self, mock_pre_run): + runner_parameters = {'host': 'host@domain.tld', + 'username': 'user@domain.tld', + 'password': 'abc123', + 'env': None} + self._runner.runner_parameters = runner_parameters + self._runner.pre_run() + mock_pre_run.assert_called_with() + # ensure that env is set to {} even though we passed in None + self.assertEquals(self._runner._env, {}) + + @mock.patch('winrm_runner.winrm_base.ActionRunner.pre_run') + def test_pre_run_ssl_verify_true(self, mock_pre_run): + runner_parameters = {'host': 'host@domain.tld', + 'username': 'user@domain.tld', + 'password': 'abc123', + 'verify_ssl_cert': True} + self._runner.runner_parameters = runner_parameters + self._runner.pre_run() + mock_pre_run.assert_called_with() + self.assertEquals(self._runner._verify_ssl, True) + self.assertEquals(self._runner._server_cert_validation, 'validate') + + @mock.patch('winrm_runner.winrm_base.ActionRunner.pre_run') + def test_pre_run_ssl_verify_false(self, mock_pre_run): + runner_parameters = {'host': 'host@domain.tld', + 'username': 'user@domain.tld', + 'password': 'abc123', + 'verify_ssl_cert': False} + self._runner.runner_parameters = runner_parameters + self._runner.pre_run() + mock_pre_run.assert_called_with() + self.assertEquals(self._runner._verify_ssl, False) + self.assertEquals(self._runner._server_cert_validation, 'ignore') + + @mock.patch('winrm_runner.winrm_base.Session') + def test_create_session(self, mock_session): + self._runner._winrm_url = 'https://host@domain.tld:5986/wsman' + self._runner._username = 'user@domain.tld' + self._runner._password = 'abc123' + self._runner._transport = 'ntlm' + self._runner._server_cert_validation = 'validate' + self._runner._timeout = 60 + self._runner._read_timeout = 61 + mock_session.return_value = "session" + + result = self._runner._create_session() + self.assertEquals(result, "session") + mock_session.assert_called_with('https://host@domain.tld:5986/wsman', + auth=('user@domain.tld', 'abc123'), + transport='ntlm', + server_cert_validation='validate', + operation_timeout_sec=60, + read_timeout_sec=61) + + def test_get_command_output(self): + self._runner._timeout = 0 + mock_protocol = mock.MagicMock() + mock_protocol._raw_get_command_output.side_effect = [ + (b'output1', b'error1', 123, False), + (b'output2', b'error2', 456, False), + (b'output3', b'error3', 789, True) + ] + + result = self._runner._winrm_get_command_output(mock_protocol, 567, 890) + + self.assertEquals(result, (b'output1output2output3', b'error1error2error3', 789)) + mock_protocol._raw_get_command_output.assert_has_calls = [ + mock.call(567, 890), + mock.call(567, 890), + mock.call(567, 890) + ] + + def test_get_command_output_timeout(self): + self._runner._timeout = 0.1 + + mock_protocol = mock.MagicMock() + + def sleep_for_timeout(*args, **kwargs): + time.sleep(0.2) + return (b'output1', b'error1', 123, False) + + mock_protocol._raw_get_command_output.side_effect = sleep_for_timeout + + with self.assertRaises(WinRmRunnerTimoutError) as cm: + self._runner._winrm_get_command_output(mock_protocol, 567, 890) + + timeout_exception = cm.exception + self.assertEqual(timeout_exception.response.std_out, b'output1') + self.assertEqual(timeout_exception.response.std_err, b'error1') + self.assertEqual(timeout_exception.response.status_code, WINRM_TIMEOUT_EXIT_CODE) + mock_protocol._raw_get_command_output.assert_called_with(567, 890) + + def test_get_command_output_operation_timeout(self): + self._runner._timeout = 0.1 + + mock_protocol = mock.MagicMock() + + def sleep_for_timeout_then_raise(*args, **kwargs): + time.sleep(0.2) + raise WinRMOperationTimeoutError() + + mock_protocol._raw_get_command_output.side_effect = sleep_for_timeout_then_raise + + with self.assertRaises(WinRmRunnerTimoutError) as cm: + self._runner._winrm_get_command_output(mock_protocol, 567, 890) + + timeout_exception = cm.exception + self.assertEqual(timeout_exception.response.std_out, b'') + self.assertEqual(timeout_exception.response.std_err, b'') + self.assertEqual(timeout_exception.response.status_code, WINRM_TIMEOUT_EXIT_CODE) + mock_protocol._raw_get_command_output.assert_called_with(567, 890) + + def test_winrm_run_cmd(self): + mock_protocol = mock.MagicMock() + mock_protocol.open_shell.return_value = 123 + mock_protocol.run_command.return_value = 456 + mock_protocol._raw_get_command_output.return_value = (b'output', b'error', 9, True) + mock_session = mock.MagicMock(protocol=mock_protocol) + + self._init_runner() + result = self._runner._winrm_run_cmd(mock_session, "fake-command", + args=['arg1', 'arg2'], + env={'PATH': 'C:\\st2\\bin'}, + cwd='C:\\st2') + expected_response = Response((b'output', b'error', 9)) + expected_response.timeout = False + + self.assertEquals(result.__dict__, expected_response.__dict__) + mock_protocol.open_shell.assert_called_with(env_vars={'PATH': 'C:\\st2\\bin'}, + working_directory='C:\\st2') + mock_protocol.run_command.assert_called_with(123, 'fake-command', ['arg1', 'arg2']) + mock_protocol._raw_get_command_output.assert_called_with(123, 456) + mock_protocol.cleanup_command.assert_called_with(123, 456) + mock_protocol.close_shell.assert_called_with(123) + + @mock.patch('winrm_runner.winrm_base.WinRmBaseRunner._winrm_get_command_output') + def test_winrm_run_cmd_timeout(self, mock_get_command_output): + mock_protocol = mock.MagicMock() + mock_protocol.open_shell.return_value = 123 + mock_protocol.run_command.return_value = 456 + mock_session = mock.MagicMock(protocol=mock_protocol) + mock_get_command_output.side_effect = WinRmRunnerTimoutError(Response(('', '', 5))) + + self._init_runner() + result = self._runner._winrm_run_cmd(mock_session, "fake-command", + args=['arg1', 'arg2'], + env={'PATH': 'C:\\st2\\bin'}, + cwd='C:\\st2') + expected_response = Response(('', '', 5)) + expected_response.timeout = True + + self.assertEquals(result.__dict__, expected_response.__dict__) + mock_protocol.open_shell.assert_called_with(env_vars={'PATH': 'C:\\st2\\bin'}, + working_directory='C:\\st2') + mock_protocol.run_command.assert_called_with(123, 'fake-command', ['arg1', 'arg2']) + mock_protocol.cleanup_command.assert_called_with(123, 456) + mock_protocol.close_shell.assert_called_with(123) + + @mock.patch('winrm_runner.winrm_base.WinRmBaseRunner._winrm_run_cmd') + def test_winrm_run_ps(self, mock_run_cmd): + mock_run_cmd.return_value = Response(('output', '', 3)) + script = "Get-ADUser stanley" + + result = self._runner._winrm_run_ps("session", script, + env={'PATH': 'C:\\st2\\bin'}, + cwd='C:\\st2') + + self.assertEquals(result.__dict__, + Response(('output', '', 3)).__dict__) + expected_ps = ('powershell -encodedcommand ' + + b64encode("Get-ADUser stanley".encode('utf_16_le')).decode('ascii')) + mock_run_cmd.assert_called_with("session", + expected_ps, + env={'PATH': 'C:\\st2\\bin'}, + cwd='C:\\st2') + + @mock.patch('winrm_runner.winrm_base.WinRmBaseRunner._winrm_run_cmd') + def test_winrm_run_ps_clean_stderr(self, mock_run_cmd): + mock_run_cmd.return_value = Response(('output', 'error', 3)) + mock_session = mock.MagicMock() + mock_session._clean_error_msg.return_value = 'e' + script = "Get-ADUser stanley" + + result = self._runner._winrm_run_ps(mock_session, script, + env={'PATH': 'C:\\st2\\bin'}, + cwd='C:\\st2') + + self.assertEquals(result.__dict__, + Response(('output', 'e', 3)).__dict__) + expected_ps = ('powershell -encodedcommand ' + + b64encode("Get-ADUser stanley".encode('utf_16_le')).decode('ascii')) + mock_run_cmd.assert_called_with(mock_session, + expected_ps, + env={'PATH': 'C:\\st2\\bin'}, + cwd='C:\\st2') + mock_session._clean_error_msg.assert_called_with('error') + + @mock.patch('winrm.Protocol') + def test_run_cmd(self, mock_protocol_init): + mock_protocol = mock.MagicMock() + mock_protocol._raw_get_command_output.side_effect = [ + (b'output1', b'error1', 0, False), + (b'output2', b'error2', 0, False), + (b'output3', b'error3', 0, True) + ] + mock_protocol_init.return_value = mock_protocol + + self._init_runner() + result = self._runner.run_cmd("ipconfig /all") + self.assertEquals(result, ('succeeded', + {'failed': False, + 'succeeded': True, + 'return_code': 0, + 'stdout': b'output1output2output3', + 'stderr': b'error1error2error3'}, + None)) + + @mock.patch('winrm.Protocol') + def test_run_cmd_failed(self, mock_protocol_init): + mock_protocol = mock.MagicMock() + mock_protocol._raw_get_command_output.side_effect = [ + (b'output1', b'error1', 0, False), + (b'output2', b'error2', 0, False), + (b'output3', b'error3', 1, True) + ] + mock_protocol_init.return_value = mock_protocol + + self._init_runner() + result = self._runner.run_cmd("ipconfig /all") + self.assertEquals(result, ('failed', + {'failed': True, + 'succeeded': False, + 'return_code': 1, + 'stdout': b'output1output2output3', + 'stderr': b'error1error2error3'}, + None)) + + @mock.patch('winrm.Protocol') + def test_run_cmd_timeout(self, mock_protocol_init): + mock_protocol = mock.MagicMock() + self._init_runner() + self._runner._timeout = 0.1 + + def sleep_for_timeout_then_raise(*args, **kwargs): + time.sleep(0.2) + return (b'output1', b'error1', 123, False) + + mock_protocol._raw_get_command_output.side_effect = sleep_for_timeout_then_raise + mock_protocol_init.return_value = mock_protocol + + result = self._runner.run_cmd("ipconfig /all") + self.assertEquals(result, ('timeout', + {'failed': True, + 'succeeded': False, + 'return_code': -1, + 'stdout': b'output1', + 'stderr': b'error1'}, + None)) + + @mock.patch('winrm.Protocol') + def test_run_ps(self, mock_protocol_init): + mock_protocol = mock.MagicMock() + mock_protocol._raw_get_command_output.side_effect = [ + (b'output1', b'error1', 0, False), + (b'output2', b'error2', 0, False), + (b'output3', b'error3', 0, True) + ] + mock_protocol_init.return_value = mock_protocol + + self._init_runner() + result = self._runner.run_ps("Get-Location") + self.assertEquals(result, ('succeeded', + {'failed': False, + 'succeeded': True, + 'return_code': 0, + 'stdout': b'output1output2output3', + 'stderr': 'error1error2error3'}, + None)) + + @mock.patch('winrm.Protocol') + def test_run_ps_failed(self, mock_protocol_init): + mock_protocol = mock.MagicMock() + mock_protocol._raw_get_command_output.side_effect = [ + (b'output1', b'error1', 0, False), + (b'output2', b'error2', 0, False), + (b'output3', b'error3', 1, True) + ] + mock_protocol_init.return_value = mock_protocol + + self._init_runner() + result = self._runner.run_ps("Get-Location") + self.assertEquals(result, ('failed', + {'failed': True, + 'succeeded': False, + 'return_code': 1, + 'stdout': b'output1output2output3', + 'stderr': 'error1error2error3'}, + None)) + + @mock.patch('winrm.Protocol') + def test_run_ps_timeout(self, mock_protocol_init): + mock_protocol = mock.MagicMock() + self._init_runner() + self._runner._timeout = 0.1 + + def sleep_for_timeout_then_raise(*args, **kwargs): + time.sleep(0.2) + return (b'output1', b'error1', 123, False) + + mock_protocol._raw_get_command_output.side_effect = sleep_for_timeout_then_raise + mock_protocol_init.return_value = mock_protocol + + result = self._runner.run_ps("Get-Location") + self.assertEquals(result, ('timeout', + {'failed': True, + 'succeeded': False, + 'return_code': -1, + 'stdout': b'output1', + 'stderr': 'error1'}, + None)) + + def test_translate_response_success(self): + response = Response(('output1', 'error1', 0)) + response.timeout = False + + result = self._runner._translate_response(response) + self.assertEquals(result, ('succeeded', + {'failed': False, + 'succeeded': True, + 'return_code': 0, + 'stdout': 'output1', + 'stderr': 'error1'}, + None)) + + def test_translate_response_failure(self): + response = Response(('output1', 'error1', 123)) + response.timeout = False + + result = self._runner._translate_response(response) + self.assertEquals(result, ('failed', + {'failed': True, + 'succeeded': False, + 'return_code': 123, + 'stdout': 'output1', + 'stderr': 'error1'}, + + None)) + + def test_translate_response_timeout(self): + response = Response(('output1', 'error1', 123)) + response.timeout = True + + result = self._runner._translate_response(response) + self.assertEquals(result, ('timeout', + {'failed': True, + 'succeeded': False, + 'return_code': -1, + 'stdout': 'output1', + 'stderr': 'error1'}, + None)) + + def test_multireplace(self): + multireplace_map = {'a': 'x', + 'c': 'y', + 'aaa': 'z'} + result = self._runner._multireplace('aaaccaa', multireplace_map) + self.assertEquals(result, 'zyyxx') + + def test_multireplace_powershell(self): + param_str = ( + '\n' + '\r' + '\t' + '\a' + '\b' + '\f' + '\v' + '"' + '\'' + '`' + '\0' + '$' + ) + result = self._runner._multireplace(param_str, PS_ESCAPE_SEQUENCES) + self.assertEquals(result, ( + '`n' + '`r' + '`t' + '`a' + '`b' + '`f' + '`v' + '`"' + '`\'' + '``' + '`0' + '`$' + )) + + def test_param_to_ps_none(self): + # test None/null + param = None + result = self._runner._param_to_ps(param) + self.assertEquals(result, '$null') + + def test_param_to_ps_string(self): + # test ascii + param_str = 'StackStorm 1234' + result = self._runner._param_to_ps(param_str) + self.assertEquals(result, '"StackStorm 1234"') + + # test escaped + param_str = '\n\r\t' + result = self._runner._param_to_ps(param_str) + self.assertEquals(result, '"`n`r`t"') + + def test_param_to_ps_bool(self): + # test True + result = self._runner._param_to_ps(True) + self.assertEquals(result, '$true') + + # test False + result = self._runner._param_to_ps(False) + self.assertEquals(result, '$false') + + def test_param_to_ps_integer(self): + result = self._runner._param_to_ps(9876) + self.assertEquals(result, '9876') + + result = self._runner._param_to_ps(-765) + self.assertEquals(result, '-765') + + def test_param_to_ps_float(self): + result = self._runner._param_to_ps(98.76) + self.assertEquals(result, '98.76') + + result = self._runner._param_to_ps(-76.5) + self.assertEquals(result, '-76.5') + + def test_param_to_ps_list(self): + input_list = ['StackStorm Test String', + '`\0$', + True, + 99] + result = self._runner._param_to_ps(input_list) + self.assertEquals(result, '@("StackStorm Test String", "```0`$", $true, 99)') + + def test_param_to_ps_list_nested(self): + input_list = [['a'], ['b'], [['c']]] + result = self._runner._param_to_ps(input_list) + self.assertEquals(result, '@(@("a"), @("b"), @(@("c")))') + + def test_param_to_ps_dict(self): + input_list = collections.OrderedDict( + [('str key', 'Value String'), + ('esc str\n', '\b\f\v"'), + (False, True), + (11, 99), + (18.3, 12.34)]) + result = self._runner._param_to_ps(input_list) + expected_str = ( + '@{"str key" = "Value String"; ' + '"esc str`n" = "`b`f`v`\""; ' + '$false = $true; ' + '11 = 99; ' + '18.3 = 12.34}' + ) + self.assertEquals(result, expected_str) + + def test_param_to_ps_dict_nexted(self): + input_list = collections.OrderedDict( + [('a', {'deep_a': 'value'}), + ('b', {'deep_b': {'deep_deep_b': 'value'}})]) + result = self._runner._param_to_ps(input_list) + expected_str = ( + '@{"a" = @{"deep_a" = "value"}; ' + '"b" = @{"deep_b" = @{"deep_deep_b" = "value"}}}' + ) + self.assertEquals(result, expected_str) + + def test_param_to_ps_deep_nested_dict_outer(self): + #### + # dict as outer container + input_dict = collections.OrderedDict( + [('a', [{'deep_a': 'value'}, + {'deep_b': ['a', 'b', 'c']}])]) + result = self._runner._param_to_ps(input_dict) + expected_str = ( + '@{"a" = @(@{"deep_a" = "value"}, ' + '@{"deep_b" = @("a", "b", "c")})}' + ) + self.assertEquals(result, expected_str) + + def test_param_to_ps_deep_nested_list_outer(self): + #### + # list as outer container + input_list = [{'deep_a': 'value'}, + {'deep_b': ['a', 'b', 'c']}, + {'deep_c': [{'x': 'y'}]}] + result = self._runner._param_to_ps(input_list) + expected_str = ( + '@(@{"deep_a" = "value"}, ' + '@{"deep_b" = @("a", "b", "c")}, ' + '@{"deep_c" = @(@{"x" = "y"})})' + ) + self.assertEquals(result, expected_str) + + def test_transform_params_to_ps(self): + positional_args = [1, 'a', '\n'] + named_args = collections.OrderedDict( + [('a', 'value1'), + ('b', True), + ('c', ['x', 'y']), + ('d', {'z': 'w'})] + ) + + result_pos, result_named = self._runner._transform_params_to_ps(positional_args, + named_args) + self.assertEquals(result_pos, ['1', '"a"', '"`n"']) + self.assertEquals(result_named, collections.OrderedDict([ + ('a', '"value1"'), + ('b', '$true'), + ('c', '@("x", "y")'), + ('d', '@{"z" = "w"}')])) + + def test_transform_params_to_ps_none(self): + positional_args = None + named_args = None + + result_pos, result_named = self._runner._transform_params_to_ps(positional_args, + named_args) + self.assertEquals(result_pos, None) + self.assertEquals(result_named, None) + + def test_create_ps_params_string(self): + positional_args = [1, 'a', '\n'] + named_args = collections.OrderedDict( + [('-a', 'value1'), + ('-b', True), + ('-c', ['x', 'y']), + ('-d', {'z': 'w'})] + ) + + result = self._runner.create_ps_params_string(positional_args, named_args) + + self.assertEquals(result, + '-a "value1" -b $true -c @("x", "y") -d @{"z" = "w"} 1 "a" "`n"') + + def test_create_ps_params_string_none(self): + positional_args = None + named_args = None + + result = self._runner.create_ps_params_string(positional_args, named_args) + self.assertEquals(result, "") diff --git a/contrib/runners/winrm_runner/tests/unit/test_winrm_command_runner.py b/contrib/runners/winrm_runner/tests/unit/test_winrm_command_runner.py new file mode 100644 index 0000000000..e95b2baa9d --- /dev/null +++ b/contrib/runners/winrm_runner/tests/unit/test_winrm_command_runner.py @@ -0,0 +1,45 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 + +import mock +from st2common.runners.base import ActionRunner +from st2tests.base import RunnerTestCase +from winrm_runner import winrm_command_runner +from winrm_runner.winrm_base import WinRmBaseRunner + + +class WinRmCommandRunnerTestCase(RunnerTestCase): + + def setUp(self): + super(WinRmCommandRunnerTestCase, self).setUpClass() + self._runner = winrm_command_runner.get_runner() + + def test_init(self): + runner = winrm_command_runner.WinRmCommandRunner('abcdef') + self.assertIsInstance(runner, WinRmBaseRunner) + self.assertIsInstance(runner, ActionRunner) + self.assertEquals(runner.runner_id, 'abcdef') + + @mock.patch('winrm_runner.winrm_command_runner.WinRmCommandRunner.run_cmd') + def test_run(self, mock_run_cmd): + mock_run_cmd.return_value = 'expected' + + self._runner.runner_parameters = {'cmd': 'ipconfig /all'} + result = self._runner.run({}) + + self.assertEquals(result, 'expected') + mock_run_cmd.assert_called_with('ipconfig /all') diff --git a/contrib/runners/winrm_runner/tests/unit/test_winrm_ps_command_runner.py b/contrib/runners/winrm_runner/tests/unit/test_winrm_ps_command_runner.py new file mode 100644 index 0000000000..8de0c348c4 --- /dev/null +++ b/contrib/runners/winrm_runner/tests/unit/test_winrm_ps_command_runner.py @@ -0,0 +1,45 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 + +import mock +from st2common.runners.base import ActionRunner +from st2tests.base import RunnerTestCase +from winrm_runner import winrm_ps_command_runner +from winrm_runner.winrm_base import WinRmBaseRunner + + +class WinRmPsCommandRunnerTestCase(RunnerTestCase): + + def setUp(self): + super(WinRmPsCommandRunnerTestCase, self).setUpClass() + self._runner = winrm_ps_command_runner.get_runner() + + def test_init(self): + runner = winrm_ps_command_runner.WinRmPsCommandRunner('abcdef') + self.assertIsInstance(runner, WinRmBaseRunner) + self.assertIsInstance(runner, ActionRunner) + self.assertEquals(runner.runner_id, 'abcdef') + + @mock.patch('winrm_runner.winrm_ps_command_runner.WinRmPsCommandRunner.run_ps') + def test_run(self, mock_run_ps): + mock_run_ps.return_value = 'expected' + + self._runner.runner_parameters = {'cmd': 'Get-ADUser stanley'} + result = self._runner.run({}) + + self.assertEquals(result, 'expected') + mock_run_ps.assert_called_with('Get-ADUser stanley') diff --git a/contrib/runners/winrm_runner/tests/unit/test_winrm_ps_script_runner.py b/contrib/runners/winrm_runner/tests/unit/test_winrm_ps_script_runner.py new file mode 100644 index 0000000000..3d0ebaf065 --- /dev/null +++ b/contrib/runners/winrm_runner/tests/unit/test_winrm_ps_script_runner.py @@ -0,0 +1,80 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 + +import mock +import os.path +from st2common.runners.base import ActionRunner +from st2tests.base import RunnerTestCase +from winrm_runner import winrm_ps_script_runner +from winrm_runner.winrm_base import WinRmBaseRunner + +FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'fixtures') + +POWERSHELL_SCRIPT_PATH = os.path.join(FIXTURES_PATH, "TestScript.ps1") + + +class WinRmPsScriptRunnerTestCase(RunnerTestCase): + + def setUp(self): + super(WinRmPsScriptRunnerTestCase, self).setUpClass() + self._runner = winrm_ps_script_runner.get_runner() + + def test_init(self): + runner = winrm_ps_script_runner.WinRmPsScriptRunner('abcdef') + self.assertIsInstance(runner, WinRmBaseRunner) + self.assertIsInstance(runner, ActionRunner) + self.assertEquals(runner.runner_id, 'abcdef') + + @mock.patch('winrm_runner.winrm_ps_script_runner.WinRmPsScriptRunner._get_script_args') + @mock.patch('winrm_runner.winrm_ps_script_runner.WinRmPsScriptRunner.run_ps') + def test_run(self, mock_run_ps, mock_get_script_args): + mock_run_ps.return_value = 'expected' + pos_args = [1, 'abc'] + named_args = {"d": {"test": ["\r", True, 3]}} + mock_get_script_args.return_value = (pos_args, named_args) + + self._runner.entry_point = POWERSHELL_SCRIPT_PATH + self._runner.runner_parameters = {} + self._runner._kwarg_op = '-' + + result = self._runner.run({}) + + self.assertEquals(result, 'expected') + mock_run_ps.assert_called_with('''& {[CmdletBinding()] +Param( + [bool]$p_bool, + [int]$p_integer, + [double]$p_number, + [string]$p_str, + [array]$p_array, + [hashtable]$p_obj, + [Parameter(Position=0)] + [string]$p_pos0, + [Parameter(Position=1)] + [string]$p_pos1 +) + + +Write-Output "p_bool = $p_bool" +Write-Output "p_integer = $p_integer" +Write-Output "p_number = $p_number" +Write-Output "p_str = $p_str" +Write-Output "p_array = $($p_array | ConvertTo-Json -Compress)" +Write-Output "p_obj = $($p_obj | ConvertTo-Json -Compress)" +Write-Output "p_pos0 = $p_pos0" +Write-Output "p_pos1 = $p_pos1" +} -d @{"test" = @("`r", $true, 3)} 1 "abc"''') diff --git a/contrib/runners/winrm_runner/winrm_runner/__init__.py b/contrib/runners/winrm_runner/winrm_runner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/runners/winrm_runner/winrm_runner/runner.yaml b/contrib/runners/winrm_runner/winrm_runner/runner.yaml new file mode 100644 index 0000000000..88a938c37f --- /dev/null +++ b/contrib/runners/winrm_runner/winrm_runner/runner.yaml @@ -0,0 +1,201 @@ +- description: A remote execution runner that executes a Command Prompt commands via WinRM on a remote host + enabled: true + name: winrm-cmd + runner_package: winrm_runner + runner_module: winrm_command_runner + runner_parameters: + cmd: + description: Arbitrary Command Prompt command to be executed on the remote host. + type: string + cwd: + description: Working directory where the command will be executed in + type: string + env: + description: Environment variables which will be available to the command (e.g. + key1=val1,key2=val2) + type: object + host: + description: A host where the command will be run + required: true + type: string + kwarg_op: + default: "-" + description: Operator to use in front of keyword args i.e. "-" or "/". + type: string + password: + description: Password used to log in. + required: true + secret: true + type: string + port: + default: 5986 + description: 'WinRM port to connect on. If using port 5985 scheme must be "http"' + required: false + type: integer + scheme: + default: "https" + description: 'Scheme to use in the WinRM URL. If using scheme "http" port must be 5985' + required: false + type: string + timeout: + default: 60 + description: Action timeout in seconds. Action will get killed if it doesn't + finish in timeout seconds. + type: integer + transport: + default: "ntlm" + description: The type of transport that WinRM will use to communicate. + See https://github.com/diyan/pywinrm#valid-transport-options + required: false + type: string + enum: + - "basic" + - "certificate" + - "credssp" + - "kerberos" + - "ntlm" + - "plaintext" + - "ssl" + username: + description: Username used to log-in. + required: true + type: string + verify_ssl_cert: + default: true + description: Certificate for HTTPS request is verified by default using requests + CA bundle which comes from Mozilla. Verification using a custom CA bundle + is not yet supported. Set to False to skip verification. + type: boolean +- description: A remote execution runner that executes PowerShell commands via WinRM on a remote host + enabled: true + name: winrm-ps-cmd + runner_package: winrm_runner + runner_module: winrm_ps_command_runner + runner_parameters: + cmd: + description: Arbitrary PowerShell command to be executed on the remote host. + type: string + cwd: + description: Working directory where the command will be executed in + type: string + env: + description: Environment variables which will be available to the command (e.g. + key1=val1,key2=val2) + type: object + host: + description: A host where the command will be run + required: true + type: string + kwarg_op: + default: "-" + description: Operator to use in front of keyword args i.e. "-" or "/". + type: string + password: + description: Password used to log in. + required: true + secret: true + type: string + port: + default: 5986 + description: 'WinRM port to connect on. If using port 5985 scheme must be "http"' + required: false + type: integer + scheme: + default: "https" + description: 'Scheme to use in the WinRM URL. If using scheme "http" port must be 5985' + required: false + type: string + timeout: + default: 60 + description: Action timeout in seconds. Action will get killed if it doesn't + finish in timeout seconds. + type: integer + transport: + default: "ntlm" + description: The type of transport that WinRM will use to communicate. + See https://github.com/diyan/pywinrm#valid-transport-options + required: false + type: string + enum: + - "basic" + - "certificate" + - "credssp" + - "kerberos" + - "ntlm" + - "plaintext" + - "ssl" + username: + description: Username used to log-in. + required: true + type: string + verify_ssl_cert: + default: true + description: Certificate for HTTPS request is verified by default using requests + CA bundle which comes from Mozilla. Verification using a custom CA bundle + is not yet supported. Set to False to skip verification. + type: boolean +- description: A remote execution runner that executes PowerShell script via WinRM on a set of remote hosts + enabled: true + name: winrm-ps-script + runner_package: winrm_runner + runner_module: winrm_ps_script_runner + runner_parameters: + cwd: + description: Working directory where the command will be executed in + type: string + env: + description: Environment variables which will be available to the command (e.g. + key1=val1,key2=val2) + type: object + host: + description: A host where the command will be run + required: true + type: string + kwarg_op: + default: "-" + description: Operator to use in front of keyword args i.e. "-" or "/". + type: string + password: + description: Password used to log in. + required: true + secret: true + type: string + port: + default: 5986 + description: 'WinRM port to connect on. If using port 5985 scheme must be "http"' + required: false + type: integer + scheme: + default: "https" + description: 'Scheme to use in the WinRM URL. If using scheme "http" port must be 5985' + required: false + type: string + timeout: + default: 60 + description: Action timeout in seconds. Action will get killed if it doesn't + finish in timeout seconds. + type: integer + transport: + default: "ntlm" + description: The type of transport that WinRM will use to communicate. + See https://github.com/diyan/pywinrm#valid-transport-options + required: false + type: string + enum: + - "basic" + - "certificate" + - "credssp" + - "kerberos" + - "ntlm" + - "plaintext" + - "ssl" + username: + description: Username used to log-in. + required: true + type: string + verify_ssl_cert: + default: true + description: Certificate for HTTPS request is verified by default using requests + CA bundle which comes from Mozilla. Verification using a custom CA bundle + is not yet supported. Set to False to skip verification. + type: boolean diff --git a/contrib/runners/winrm_runner/winrm_runner/winrm_base.py b/contrib/runners/winrm_runner/winrm_runner/winrm_base.py new file mode 100644 index 0000000000..8cfbb6e174 --- /dev/null +++ b/contrib/runners/winrm_runner/winrm_runner/winrm_base.py @@ -0,0 +1,307 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 + +import re +import six +import time + +from base64 import b64encode +from st2common import log as logging +from st2common.constants import action as action_constants +from st2common.constants import exit_codes as exit_code_constants +from st2common.runners.base import ActionRunner +from st2common.util import jsonify +from winrm import Session, Response +from winrm.exceptions import WinRMOperationTimeoutError + +__all__ = [ + 'WinRmBaseRunner', +] + +LOG = logging.getLogger(__name__) + +RUNNER_CWD = "cwd" +RUNNER_ENV = "env" +RUNNER_HOST = "host" +RUNNER_KWARG_OP = "kwarg_op" +RUNNER_PASSWORD = "password" +RUNNER_PORT = "port" +RUNNER_SCHEME = "scheme" +RUNNER_TIMEOUT = "timeout" +RUNNER_TRANSPORT = "transport" +RUNNER_USERNAME = "username" +RUNNER_VERIFY_SSL = "verify_ssl_cert" + +WINRM_HTTPS_PORT = 5986 +WINRM_HTTP_PORT = 5985 +# explicity made so that it does not equal SUCCESS so a failure is returned +WINRM_TIMEOUT_EXIT_CODE = exit_code_constants.SUCCESS_EXIT_CODE - 1 + +DEFAULT_KWARG_OP = "-" +DEFAULT_PORT = WINRM_HTTPS_PORT +DEFAULT_SCHEME = "https" +DEFAULT_TIMEOUT = 60 +DEFAULT_TRANSPORT = "ntlm" +DEFAULT_VERIFY_SSL = True + +RESULT_KEYS_TO_TRANSFORM = ["stdout", "stderr"] + +# key = value in linux/bash to escape +# value = powershell escaped equivalent +# +# Compiled list from the following sources: +# https://ss64.com/ps/syntax-esc.html +# https://www.techotopia.com/index.php/Windows_PowerShell_1.0_String_Quoting_and_Escape_Sequences#PowerShell_Special_Escape_Sequences +PS_ESCAPE_SEQUENCES = {'\n': '`n', + '\r': '`r', + '\t': '`t', + '\a': '`a', + '\b': '`b', + '\f': '`f', + '\v': '`v', + '"': '`"', + '\'': '`\'', + '`': '``', + '\0': '`0', + '$': '`$'} + + +class WinRmRunnerTimoutError(Exception): + + def __init__(self, response): + self.response = response + + +class WinRmBaseRunner(ActionRunner): + + def pre_run(self): + super(WinRmBaseRunner, self).pre_run() + + # common connection parameters + self._host = self.runner_parameters[RUNNER_HOST] + self._username = self.runner_parameters[RUNNER_USERNAME] + self._password = self.runner_parameters[RUNNER_PASSWORD] + self._timeout = self.runner_parameters.get(RUNNER_TIMEOUT, DEFAULT_TIMEOUT) + self._read_timeout = self._timeout + 1 # read_timeout must be > operation_timeout + + # default to https port 5986 over ntlm + self._port = self.runner_parameters.get(RUNNER_PORT, DEFAULT_PORT) + self._scheme = self.runner_parameters.get(RUNNER_SCHEME, DEFAULT_SCHEME) + self._transport = self.runner_parameters.get(RUNNER_TRANSPORT, DEFAULT_TRANSPORT) + + # if connecting to the HTTP port then we must use "http" as the scheme + # in the URL + if self._port == WINRM_HTTP_PORT: + self._scheme = "http" + + # construct the URL for connecting to WinRM on the host + self._winrm_url = "{}://{}:{}/wsman".format(self._scheme, self._host, self._port) + + # default to verifying SSL certs + self._verify_ssl = self.runner_parameters.get(RUNNER_VERIFY_SSL, DEFAULT_VERIFY_SSL) + self._server_cert_validation = "validate" if self._verify_ssl else "ignore" + + # additional parameters + self._cwd = self.runner_parameters.get(RUNNER_CWD, None) + self._env = self.runner_parameters.get(RUNNER_ENV, {}) + self._env = self._env or {} + self._kwarg_op = self.runner_parameters.get(RUNNER_KWARG_OP, DEFAULT_KWARG_OP) + + def _create_session(self): + # create the session + LOG.info("Connecting via WinRM to url: {}".format(self._winrm_url)) + session = Session(self._winrm_url, + auth=(self._username, self._password), + transport=self._transport, + server_cert_validation=self._server_cert_validation, + operation_timeout_sec=self._timeout, + read_timeout_sec=self._read_timeout) + return session + + def _winrm_get_command_output(self, protocol, shell_id, command_id): + # NOTE: this is copied from pywinrm because it doesn't support + # timeouts + stdout_buffer, stderr_buffer = [], [] + return_code = 0 + command_done = False + start_time = time.time() + while not command_done: + # check if we need to timeout (StackStorm custom) + current_time = time.time() + elapsed_time = (current_time - start_time) + if self._timeout and (elapsed_time > self._timeout): + raise WinRmRunnerTimoutError(Response((b''.join(stdout_buffer), + b''.join(stderr_buffer), + WINRM_TIMEOUT_EXIT_CODE))) + # end stackstorm custom + + try: + stdout, stderr, return_code, command_done = \ + protocol._raw_get_command_output(shell_id, command_id) + stdout_buffer.append(stdout) + stderr_buffer.append(stderr) + except WinRMOperationTimeoutError: + # this is an expected error when waiting for a long-running process, + # just silently retry + pass + return b''.join(stdout_buffer), b''.join(stderr_buffer), return_code + + def _winrm_run_cmd(self, session, command, args=(), env=None, cwd=None): + # NOTE: this is copied from pywinrm because it doesn't support + # passing env and working_directory from the Session.run_cmd. + # It also doesn't support timeouts. All of these things have been + # added + shell_id = session.protocol.open_shell(env_vars=env, + working_directory=cwd) + command_id = session.protocol.run_command(shell_id, command, args) + # try/catch is for custom timeout handing (StackStorm custom) + try: + rs = Response(self._winrm_get_command_output(session.protocol, + shell_id, + command_id)) + rs.timeout = False + except WinRmRunnerTimoutError as e: + rs = e.response + rs.timeout = True + # end stackstorm custom + session.protocol.cleanup_command(shell_id, command_id) + session.protocol.close_shell(shell_id) + return rs + + def _winrm_run_ps(self, session, script, env=None, cwd=None): + # NOTE: this is copied from pywinrm because it doesn't support + # passing env and working_directory from the Session.run_ps + encoded_ps = b64encode(script.encode('utf_16_le')).decode('ascii') + rs = self._winrm_run_cmd(session, + 'powershell -encodedcommand {0}'.format(encoded_ps), + env=env, + cwd=cwd) + if len(rs.std_err): + # if there was an error message, clean it it up and make it human + # readable + if isinstance(rs.std_err, bytes): + # decode bytes into utf-8 because of a bug in pywinrm + # real fix is here: https://github.com/diyan/pywinrm/pull/222/files + rs.std_err = rs.std_err.decode('utf-8') + rs.std_err = session._clean_error_msg(rs.std_err) + return rs + + def _translate_response(self, response): + # check exit status for errors + succeeded = (response.status_code == exit_code_constants.SUCCESS_EXIT_CODE) + status = action_constants.LIVEACTION_STATUS_SUCCEEDED + status_code = response.status_code + if response.timeout: + status = action_constants.LIVEACTION_STATUS_TIMED_OUT + status_code = WINRM_TIMEOUT_EXIT_CODE + elif not succeeded: + status = action_constants.LIVEACTION_STATUS_FAILED + + # create result + result = { + 'failed': not succeeded, + 'succeeded': succeeded, + 'return_code': status_code, + 'stdout': response.std_out, + 'stderr': response.std_err + } + + # automatically convert result stdout/stderr from JSON strings to + # objects so they can be used natively + return (status, jsonify.json_loads(result, RESULT_KEYS_TO_TRANSFORM), None) + + def run_cmd(self, cmd): + # connect + session = self._create_session() + # execute + response = self._winrm_run_cmd(session, cmd, env=self._env, cwd=self._cwd) + # create triplet from WinRM response + return self._translate_response(response) + + def run_ps(self, powershell): + # connect + session = self._create_session() + # execute + response = self._winrm_run_ps(session, powershell, env=self._env, cwd=self._cwd) + # create triplet from WinRM response + return self._translate_response(response) + + def _multireplace(self, string, replacements): + """ + Given a string and a replacement map, it returns the replaced string. + Source = https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729 + Reference = https://stackoverflow.com/questions/6116978/how-to-replace-multiple-substrings-of-a-string # noqa + :param str string: string to execute replacements on + :param dict replacements: replacement dictionary {value to find: value to replace} + :rtype: str + """ + # Place longer ones first to keep shorter substrings from matching where + # the longer ones should take place + # For instance given the replacements {'ab': 'AB', 'abc': 'ABC'} against + # the string 'hey abc', it should produce 'hey ABC' and not 'hey ABc' + substrs = sorted(replacements, key=len, reverse=True) + + # Create a big OR regex that matches any of the substrings to replace + regexp = re.compile('|'.join([re.escape(s) for s in substrs])) + + # For each match, look up the new string in the replacements + return regexp.sub(lambda match: replacements[match.group(0)], string) + + def _param_to_ps(self, param): + ps_str = "" + if param is None: + ps_str = "$null" + elif isinstance(param, six.string_types): + ps_str = '"' + self._multireplace(param, PS_ESCAPE_SEQUENCES) + '"' + elif isinstance(param, bool): + ps_str = "$true" if param else "$false" + elif isinstance(param, list): + ps_str = "@(" + ps_str += ", ".join([self._param_to_ps(p) for p in param]) + ps_str += ")" + elif isinstance(param, dict): + ps_str = "@{" + ps_str += "; ".join([(self._param_to_ps(k) + ' = ' + self._param_to_ps(v)) + for k, v in six.iteritems(param)]) + ps_str += "}" + else: + ps_str = str(param) + return ps_str + + def _transform_params_to_ps(self, positional_args, named_args): + if positional_args: + for i, arg in enumerate(positional_args): + positional_args[i] = self._param_to_ps(arg) + + if named_args: + for key, value in six.iteritems(named_args): + named_args[key] = self._param_to_ps(value) + + return positional_args, named_args + + def create_ps_params_string(self, positional_args, named_args): + # convert the script parameters into powershell strings + positional_args, named_args = self._transform_params_to_ps(positional_args, + named_args) + # concatenate them into a long string + ps_params_str = "" + if named_args: + ps_params_str += " " .join([(k + " " + v) for k, v in six.iteritems(named_args)]) + ps_params_str += " " + if positional_args: + ps_params_str += " ".join(positional_args) + return ps_params_str diff --git a/contrib/runners/winrm_runner/winrm_runner/winrm_command_runner.py b/contrib/runners/winrm_runner/winrm_runner/winrm_command_runner.py new file mode 100644 index 0000000000..8a15456f9b --- /dev/null +++ b/contrib/runners/winrm_runner/winrm_runner/winrm_command_runner.py @@ -0,0 +1,48 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 +import uuid + +from st2common import log as logging +from st2common.runners.base import get_metadata as get_runner_metadata +from winrm_runner.winrm_base import WinRmBaseRunner + +__all__ = [ + 'WinRmCommandRunner', + 'get_runner', + 'get_metadata' +] + +LOG = logging.getLogger(__name__) + +RUNNER_COMMAND = 'cmd' + + +class WinRmCommandRunner(WinRmBaseRunner): + + def run(self, action_parameters): + cmd_command = self.runner_parameters[RUNNER_COMMAND] + + # execute + return self.run_cmd(cmd_command) + + +def get_runner(): + return WinRmCommandRunner(str(uuid.uuid4())) + + +def get_metadata(): + return get_runner_metadata('winrm_command_runner') diff --git a/contrib/runners/winrm_runner/winrm_runner/winrm_ps_command_runner.py b/contrib/runners/winrm_runner/winrm_runner/winrm_ps_command_runner.py new file mode 100644 index 0000000000..4b1fb00c65 --- /dev/null +++ b/contrib/runners/winrm_runner/winrm_runner/winrm_ps_command_runner.py @@ -0,0 +1,48 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 +import uuid + +from st2common import log as logging +from st2common.runners.base import get_metadata as get_runner_metadata +from winrm_runner.winrm_base import WinRmBaseRunner + +__all__ = [ + 'WinRmPsCommandRunner', + 'get_runner', + 'get_metadata' +] + +LOG = logging.getLogger(__name__) + +RUNNER_COMMAND = 'cmd' + + +class WinRmPsCommandRunner(WinRmBaseRunner): + + def run(self, action_parameters): + powershell_command = self.runner_parameters[RUNNER_COMMAND] + + # execute + return self.run_ps(powershell_command) + + +def get_runner(): + return WinRmPsCommandRunner(str(uuid.uuid4())) + + +def get_metadata(): + return get_runner_metadata('winrm_ps_command_runner') diff --git a/contrib/runners/winrm_runner/winrm_runner/winrm_ps_script_runner.py b/contrib/runners/winrm_runner/winrm_runner/winrm_ps_script_runner.py new file mode 100644 index 0000000000..b48956d1e0 --- /dev/null +++ b/contrib/runners/winrm_runner/winrm_runner/winrm_ps_script_runner.py @@ -0,0 +1,65 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 +import uuid + +from st2common import log as logging +from st2common.runners.base import ShellRunnerMixin +from st2common.runners.base import get_metadata as get_runner_metadata +from winrm_runner.winrm_base import WinRmBaseRunner + +__all__ = [ + 'WinRmPsScriptRunner', + 'get_runner', + 'get_metadata' +] + +LOG = logging.getLogger(__name__) + + +class WinRmPsScriptRunner(WinRmBaseRunner, ShellRunnerMixin): + + def run(self, action_parameters): + if not self.entry_point: + raise ValueError('Missing entry_point action metadata attribute') + + # read in the script contents from the local file + with open(self.entry_point, 'r') as script_file: + ps_script = script_file.read() + + # extract script parameters specified in the action metadata file + positional_args, named_args = self._get_script_args(action_parameters) + named_args = self._transform_named_args(named_args) + + # build a string from all of the named and positional arguments + # this will be our full parameter list when executing the script + ps_params = self.create_ps_params_string(positional_args, named_args) + + # the following wraps the script (from the file) in a script block ( {} ) + # executes it, passing in the parameters built above + # https://docs.microsoft.com/en-us/powershell/scripting/core-powershell/console/powershell.exe-command-line-help + ps_script_and_params = "& {%s} %s" % (ps_script, ps_params) + + # execute + return self.run_ps(ps_script_and_params) + + +def get_runner(): + return WinRmPsScriptRunner(str(uuid.uuid4())) + + +def get_metadata(): + return get_runner_metadata('winrm_ps_script_runner') diff --git a/fixed-requirements.txt b/fixed-requirements.txt index c89806badc..2688327c37 100644 --- a/fixed-requirements.txt +++ b/fixed-requirements.txt @@ -47,6 +47,7 @@ routes==2.4.1 flex==6.13.1 webob==1.7.4 prance==0.9.0 +pywinrm==0.3.0 # test requirements below nose-timer>=0.7.2,<0.8 psutil==5.4.5 diff --git a/requirements.txt b/requirements.txt index 6a730598df..d66b17f085 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,6 +41,7 @@ python-gnupg==0.4.2 python-json-logger python-statsd==2.1.0 pytz==2018.4 +pywinrm==0.3.0 pyyaml<4.0,>=3.12 rednose requests[security]<2.15,>=2.14.1 diff --git a/st2api/tests/unit/controllers/v1/test_packs.py b/st2api/tests/unit/controllers/v1/test_packs.py index 3386055a5c..23faf9775c 100644 --- a/st2api/tests/unit/controllers/v1/test_packs.py +++ b/st2api/tests/unit/controllers/v1/test_packs.py @@ -516,14 +516,14 @@ def test_packs_register_endpoint(self, mock_get_packs): {'packs': ['dummy_pack_1'], 'types': ['action']}) self.assertEqual(resp.status_int, 200) - self.assertEqual(resp.json, {'actions': 1, 'runners': 15}) + self.assertEqual(resp.json, {'actions': 1, 'runners': 18}) # Verify that plural name form also works resp = self.app.post_json('/v1/packs/register', {'packs': ['dummy_pack_1'], 'types': ['actions']}) self.assertEqual(resp.status_int, 200) - self.assertEqual(resp.json, {'actions': 1, 'runners': 15}) + self.assertEqual(resp.json, {'actions': 1, 'runners': 18}) # Register single resource from a single pack specified multiple times - verify that # resources from the same pack are only registered once @@ -533,7 +533,7 @@ def test_packs_register_endpoint(self, mock_get_packs): 'fail_on_failure': False}) self.assertEqual(resp.status_int, 200) - self.assertEqual(resp.json, {'actions': 1, 'runners': 15}) + self.assertEqual(resp.json, {'actions': 1, 'runners': 18}) # Register resources from a single (non-existent pack) resp = self.app.post_json('/v1/packs/register', {'packs': ['doesntexist']}, diff --git a/st2tests/st2tests/fixtures/packs/runners/winrm_runner b/st2tests/st2tests/fixtures/packs/runners/winrm_runner new file mode 120000 index 0000000000..5bb8e19cfe --- /dev/null +++ b/st2tests/st2tests/fixtures/packs/runners/winrm_runner @@ -0,0 +1 @@ +../../../../../contrib/runners/winrm_runner \ No newline at end of file diff --git a/tox.ini b/tox.ini index 9e669c6241..a0c1973e6c 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ deps = -r{toxinidir}/requirements.txt # Python 3 tasks [testenv:py36-unit] basepython = python3.6 -setenv = PYTHONPATH = {toxinidir}/external:{toxinidir}/st2common:{toxinidir}/st2api:{toxinidir}/st2actions:{toxinidir}/st2exporter:{toxinidir}/st2reactor:{toxinidir}/st2tests:{toxinidir}/contrib/runners/action_chain_runner:{toxinidir}/contrib/runners/local_runner:{toxinidir}/contrib/runners/windows_runner:{toxinidir}/contrib/runners/python_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/noop_runner:{toxinidir}/contrib/runners/announcement_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/mistral_v2:{toxinidir}/contrib/runners/orchestra:{toxinidir}/contrib/runners/inquirer_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/cloudslang_runner +setenv = PYTHONPATH = {toxinidir}/external:{toxinidir}/st2common:{toxinidir}/st2api:{toxinidir}/st2actions:{toxinidir}/st2exporter:{toxinidir}/st2reactor:{toxinidir}/st2tests:{toxinidir}/contrib/runners/action_chain_runner:{toxinidir}/contrib/runners/local_runner:{toxinidir}/contrib/runners/windows_runner:{toxinidir}/contrib/runners/python_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/noop_runner:{toxinidir}/contrib/runners/announcement_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/mistral_v2:{toxinidir}/contrib/runners/orchestra:{toxinidir}/contrib/runners/inquirer_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/cloudslang_runner:{toxinidir}/contrib/runners/winrm_runner VIRTUALENV_DIR = {envdir} install_command = pip install -U --force-reinstall {opts} {packages} deps = virtualenv @@ -49,10 +49,11 @@ commands = nosetests --with-timer --rednose -sv contrib/runners/orchestra_runner/tests/unit/ nosetests --with-timer --rednose -sv contrib/runners/python_runner/tests/unit/ nosetests --with-timer --rednose -sv contrib/runners/windows_runner/tests/unit/ + nosetests --with-timer --rednose -sv contrib/runners/winrm_runner/tests/unit/ [testenv:py36-integration] basepython = python3.6 -setenv = PYTHONPATH = {toxinidir}/external:{toxinidir}/st2common:{toxinidir}/st2auth:{toxinidir}/st2api:{toxinidir}/st2actions:{toxinidir}/st2exporter:{toxinidir}/st2reactor:{toxinidir}/st2tests:{toxinidir}/contrib/runners/action_chain_runner:{toxinidir}/contrib/runners/local_runner:{toxinidir}/contrib/runners/windows_runner:{toxinidir}/contrib/runners/python_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/noop_runner:{toxinidir}/contrib/runners/announcement_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/mistral_v2:{toxinidir}/contrib/runners/inquirer_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/cloudslang_runner +setenv = PYTHONPATH = {toxinidir}/external:{toxinidir}/st2common:{toxinidir}/st2auth:{toxinidir}/st2api:{toxinidir}/st2actions:{toxinidir}/st2exporter:{toxinidir}/st2reactor:{toxinidir}/st2tests:{toxinidir}/contrib/runners/action_chain_runner:{toxinidir}/contrib/runners/local_runner:{toxinidir}/contrib/runners/windows_runner:{toxinidir}/contrib/runners/python_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/noop_runner:{toxinidir}/contrib/runners/announcement_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/remote_runner:{toxinidir}/contrib/runners/mistral_v2:{toxinidir}/contrib/runners/orchestra:{toxinidir}/contrib/runners/inquirer_runner:{toxinidir}/contrib/runners/http_runner:{toxinidir}/contrib/runners/cloudslang_runner:{toxinidir}/contrib/runners/winrm_runner VIRTUALENV_DIR = {envdir} install_command = pip install -U --force-reinstall {opts} {packages} deps = virtualenv