diff --git a/.zuul.yaml b/.zuul.yaml index 40e64c3a61..4caed89ec8 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -45,6 +45,12 @@ vars: zuul_ansible_version: 2.9 +- job: + name: zuul-stream-functional-5 + parent: zuul-stream-functional + vars: + zuul_ansible_version: 5 + - job: name: zuul-tox description: | @@ -300,6 +306,7 @@ - web/.* - zuul-stream-functional-2.8 - zuul-stream-functional-2.9 + - zuul-stream-functional-5 - zuul-tox-remote - zuul-quick-start: requires: nodepool-container-image @@ -326,6 +333,7 @@ - web/.* - zuul-stream-functional-2.8 - zuul-stream-functional-2.9 + - zuul-stream-functional-5 - zuul-tox-remote - zuul-quick-start: requires: nodepool-container-image diff --git a/playbooks/zuul-stream/fixtures/library/zuul_fail.py b/playbooks/zuul-stream/fixtures/library/zuul_fail.py new file mode 100644 index 0000000000..4f5b776d23 --- /dev/null +++ b/playbooks/zuul-stream/fixtures/library/zuul_fail.py @@ -0,0 +1,35 @@ +#!/usr/bin/python + +# Copyright: (c) 2022, Acme Gating LLC +# +# This module is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this software. If not, see . + +from ansible.module_utils.basic import AnsibleModule + + +def run_module(): + module = AnsibleModule( + argument_spec=dict(key=dict(type='str', required=True)), + supports_check_mode=True + ) + + raise Exception("Test module failure exception " + module.params['key']) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/playbooks/zuul-stream/fixtures/test-stream-failure.yaml b/playbooks/zuul-stream/fixtures/test-stream-failure.yaml index 3e7b27a7f8..febae1b2a5 100644 --- a/playbooks/zuul-stream/fixtures/test-stream-failure.yaml +++ b/playbooks/zuul-stream/fixtures/test-stream-failure.yaml @@ -4,18 +4,16 @@ - block: - - name: Run a shell task with an ansible python exception - command: echo foo - args: - chdir: /failure-shelltask/somewhere/that/does/not/exist + - name: Run a task with an ansible python exception + zuul_fail: + key: fail-task always: - name: Loop with items on an ansible python exception - command: "echo {{ item }}" + zuul_fail: + key: fail-loop with_items: - item1 - item2 - item3 - args: - chdir: /failure-itemloop/somewhere/that/does/not/exist diff --git a/playbooks/zuul-stream/fixtures/test-stream.yaml b/playbooks/zuul-stream/fixtures/test-stream.yaml index 413fdee60a..05b75daf99 100644 --- a/playbooks/zuul-stream/fixtures/test-stream.yaml +++ b/playbooks/zuul-stream/fixtures/test-stream.yaml @@ -35,20 +35,18 @@ - name: complex2 - name: complex3 - - name: Run a shell task with an ansible python exception - command: echo foo - args: - chdir: /shelltask/somewhere/that/does/not/exist + - name: Run a task with an ansible python exception + zuul_fail: + key: task failed_when: false - name: Loop with items on an ansible python exception - command: "echo {{ item }}" + zuul_fail: + key: loop with_items: - item1 - item2 - item3 - args: - chdir: /itemloop/somewhere/that/does/not/exist failed_when: false - name: Print binary data diff --git a/playbooks/zuul-stream/functional.yaml b/playbooks/zuul-stream/functional.yaml index 0703ad5b53..fbc391ac0a 100644 --- a/playbooks/zuul-stream/functional.yaml +++ b/playbooks/zuul-stream/functional.yaml @@ -51,25 +51,25 @@ egrep "^.+\| node1 \| ok: Item: Runtime" job-output.txt egrep "^.+\| node2 \| ok: Item: Runtime" job-output.txt - - name: Validate output - shell task with exception + - name: Validate output - failed shell task shell: | - egrep "^.+\| node1 \| (OSError|FileNotFoundError).+\/shelltask\/" job-output.txt - egrep "^.+\| node2 \| (OSError|FileNotFoundError).+\/shelltask\/" job-output.txt + egrep "^.+\| node1 \| Exception: Test module failure exception task" job-output.txt + egrep "^.+\| node2 \| Exception: Test module failure exception task" job-output.txt - name: Validate output - item loop with exception shell: | - egrep "^.+\| node1 \| (OSError|FileNotFoundError).+\/itemloop\/" job-output.txt - egrep "^.+\| node2 \| (OSError|FileNotFoundError).+\/itemloop\/" job-output.txt + egrep "^.+\| node1 \| Exception: Test module failure exception loop" job-output.txt + egrep "^.+\| node2 \| Exception: Test module failure exception loop" job-output.txt - name: Validate output - failure shell task with exception shell: | - egrep "^.+\| node1 \| (OSError|FileNotFoundError).+\/failure-shelltask\/" job-output.txt - egrep "^.+\| node2 \| (OSError|FileNotFoundError).+\/failure-shelltask\/" job-output.txt + egrep "^.+\| node1 \| Exception: Test module failure exception fail-task" job-output.txt + egrep "^.+\| node2 \| Exception: Test module failure exception fail-task" job-output.txt - name: Validate output - failure item loop with exception shell: | - egrep "^.+\| node1 \| (OSError|FileNotFoundError).+\/failure-itemloop\/" job-output.txt - egrep "^.+\| node2 \| (OSError|FileNotFoundError).+\/failure-itemloop\/" job-output.txt + egrep "^.+\| node1 \| Exception: Test module failure exception fail-loop" job-output.txt + egrep "^.+\| node2 \| Exception: Test module failure exception fail-loop" job-output.txt - name: Validate output - binary data shell: | diff --git a/releasenotes/notes/ansible-5-0c9d6626294579e0.yaml b/releasenotes/notes/ansible-5-0c9d6626294579e0.yaml new file mode 100644 index 0000000000..4d8e8fce67 --- /dev/null +++ b/releasenotes/notes/ansible-5-0c9d6626294579e0.yaml @@ -0,0 +1,27 @@ +--- +features: + - | + Ansible version 5 is now available. The default Ansible version + is still 2.9, but version 5 may be selected by using + :attr:`job.ansible-version`. + +upgrade: + - | + This is the first version of Ansible added to Zuul since the + Ansible project began releasing the `Ansible community` package. + Zuul includes the Ansible community package since it includes a + wide selection of useful modules, many of which were included by + default in previous versions of Ansible. + + Only the major version of Ansible community is specified + (e.g. ``ansible-version: 5``). This corresponds to a single minor + release of Ansible core (e.g., Ansible community 5 corresponds to + Ansible core 2.12). Ansible releases minor versions of the + community package which may contain updates to the included + Ansible collections as well as micro version updates of Ansible + core (e.g. Ansible community 5.6 includes ansible-core 2.12.4). + + Zuul does not specify the minor version of Ansible community, + therefore the latest available micro-version will be installed at + build-time. If you need more control over the version of Ansible + used, see the help text for ``zuul-manage-ansible``. diff --git a/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml b/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml index 8cef613a4e..91c8d6bcae 100644 --- a/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml +++ b/tests/fixtures/config/ansible-versions/git/common-config/zuul.yaml @@ -52,6 +52,13 @@ test_ansible_version_major: 2 test_ansible_version_minor: 9 +- job: + name: ansible-5 + parent: ansible-version + ansible-version: 5 + vars: + test_ansible_version_major: 2 + test_ansible_version_minor: 12 - project: name: common-config @@ -60,6 +67,7 @@ - ansible-default - ansible-28 - ansible-29 + - ansible-5 - project: name: org/project @@ -68,3 +76,4 @@ - ansible-default-zuul-conf - ansible-28 - ansible-29 + - ansible-5 diff --git a/tests/fixtures/config/inventory/git/common-config/zuul.yaml b/tests/fixtures/config/inventory/git/common-config/zuul.yaml index 95a95a1959..37da852404 100644 --- a/tests/fixtures/config/inventory/git/common-config/zuul.yaml +++ b/tests/fixtures/config/inventory/git/common-config/zuul.yaml @@ -107,3 +107,21 @@ label: ubuntu-xenial ansible-version: '2.8' run: playbooks/ansible-version.yaml + +- job: + name: ansible-version29-inventory + nodeset: + nodes: + - name: ubuntu-xenial + label: ubuntu-xenial + ansible-version: '2.9' + run: playbooks/ansible-version.yaml + +- job: + name: ansible-version5-inventory + nodeset: + nodes: + - name: ubuntu-xenial + label: ubuntu-xenial + ansible-version: '5' + run: playbooks/ansible-version.yaml diff --git a/tests/fixtures/config/inventory/git/org_project/.zuul.yaml b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml index fd615518da..1028a0ff26 100644 --- a/tests/fixtures/config/inventory/git/org_project/.zuul.yaml +++ b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml @@ -8,3 +8,5 @@ - group-inventory - hostvars-inventory - ansible-version28-inventory + - ansible-version29-inventory + - ansible-version5-inventory diff --git a/tests/fixtures/config/remote-zuul-stream/git/common-config/playbooks/command-localhost.yaml b/tests/fixtures/config/remote-zuul-stream/git/common-config/playbooks/command-localhost.yaml deleted file mode 100644 index 629f3398cb..0000000000 --- a/tests/fixtures/config/remote-zuul-stream/git/common-config/playbooks/command-localhost.yaml +++ /dev/null @@ -1,7 +0,0 @@ -- hosts: localhost - tasks: - - name: Local shell task with python exception - command: echo foo - args: - chdir: /local-shelltask/somewhere/that/does/not/exist - failed_when: false diff --git a/tests/fixtures/config/remote-zuul-stream/git/common-config/zuul.yaml b/tests/fixtures/config/remote-zuul-stream/git/common-config/zuul.yaml index f9ad5fcfef..a07342e2ec 100644 --- a/tests/fixtures/config/remote-zuul-stream/git/common-config/zuul.yaml +++ b/tests/fixtures/config/remote-zuul-stream/git/common-config/zuul.yaml @@ -15,7 +15,3 @@ - job: name: base parent: null - -- job: - name: command-localhost - run: playbooks/command-localhost.yaml diff --git a/tests/fixtures/config/remote-zuul-stream/git/org_project/playbooks/command.yaml b/tests/fixtures/config/remote-zuul-stream/git/org_project/playbooks/command.yaml index ea772d6076..d6a53be74d 100644 --- a/tests/fixtures/config/remote-zuul-stream/git/org_project/playbooks/command.yaml +++ b/tests/fixtures/config/remote-zuul-stream/git/org_project/playbooks/command.yaml @@ -89,11 +89,3 @@ - failed_in_loop1 - failed_in_loop2 ignore_errors: True - -- hosts: all - tasks: - - name: Remote shell task with python exception - command: echo foo - args: - chdir: /remote-shelltask/somewhere/that/does/not/exist - failed_when: false diff --git a/tests/remote/test_remote_action_modules.py b/tests/remote/test_remote_action_modules.py index d8dee6ee30..bbe6db0a03 100644 --- a/tests/remote/test_remote_action_modules.py +++ b/tests/remote/test_remote_action_modules.py @@ -101,3 +101,11 @@ class TestActionModules29(AnsibleZuulTestCase, FunctionalActionModulesMixIn): def setUp(self): super().setUp() self._setUp() + + +class TestActionModules5(AnsibleZuulTestCase, FunctionalActionModulesMixIn): + ansible_version = '5' + + def setUp(self): + super().setUp() + self._setUp() diff --git a/tests/remote/test_remote_zuul_json.py b/tests/remote/test_remote_zuul_json.py index 36db277d8c..120235ec95 100644 --- a/tests/remote/test_remote_zuul_json.py +++ b/tests/remote/test_remote_zuul_json.py @@ -158,3 +158,11 @@ class TestZuulJSON29(AnsibleZuulTestCase, FunctionalZuulJSONMixIn): def setUp(self): super().setUp() self._setUp() + + +class TestZuulJSON5(AnsibleZuulTestCase, FunctionalZuulJSONMixIn): + ansible_version = '5' + + def setUp(self): + super().setUp() + self._setUp() diff --git a/tests/remote/test_remote_zuul_stream.py b/tests/remote/test_remote_zuul_stream.py index aed5007694..ab8026d925 100644 --- a/tests/remote/test_remote_zuul_stream.py +++ b/tests/remote/test_remote_zuul_stream.py @@ -26,7 +26,8 @@ class FunctionalZuulStreamMixIn: wait_timeout = 120 def _setUp(self): - self.log_console_port = 19000 + int(self.ansible_version.split('.')[1]) + self.log_console_port = 19000 + int( + self.ansible_core_version.split('.')[1]) self.fake_nodepool.remote_ansible = True ansible_remote = os.environ.get('ZUUL_REMOTE_IPV4') @@ -109,7 +110,7 @@ class FunctionalZuulStreamMixIn: r'playbooks/command.yaml@master\]', text) self.assertLogLine(r'PLAY \[all\]', text) self.assertLogLine( - r'Ansible version={}'.format(self.ansible_version), text) + r'Ansible version={}'.format(self.ansible_core_version), text) self.assertLogLine(r'TASK \[Show contents of first file\]', text) self.assertLogLine(r'controller \| command test one', text) self.assertLogLine( @@ -149,12 +150,6 @@ class FunctionalZuulStreamMixIn: self.assertLogLine(r'compute1 \| failed_in_loop2', text) self.assertLogLine(r'compute1 \| ok: Item: failed_in_loop2 ' r'Result: 1', text) - self.assertLogLine(r'compute1 \| .*No such file or directory: .*' - r'\'/remote-shelltask/somewhere/' - r'that/does/not/exist\'', text) - self.assertLogLine(r'controller \| .*No such file or directory: .*' - r'\'/remote-shelltask/somewhere/' - r'that/does/not/exist\'', text) self.assertLogLine( r'controller \| ok: Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) self.assertLogLine('PLAY RECAP', text) @@ -167,18 +162,6 @@ class FunctionalZuulStreamMixIn: r'RUN END RESULT_NORMAL: \[untrusted : review.example.com/' r'org/project/playbooks/command.yaml@master]', text) - # Run a pre-defined job that is defined in a trusted repo to test - # localhost tasks. - job = self._run_job('command-localhost', create=False) - with self.jobLog(job): - build = self.history[-1] - self.assertEqual(build.result, 'SUCCESS') - - text = self._get_job_output(build) - self.assertLogLine(r'localhost \| .*No such file or directory: .*' - r'\'/local-shelltask/somewhere/' - r'that/does/not/exist\'', text) - def test_module_exception(self): job = self._run_job('module_failure_exception') with self.jobLog(job): @@ -201,19 +184,14 @@ class FunctionalZuulStreamMixIn: text = self._get_job_output(build) self.assertLogLine(r'TASK \[Module failure\]', text) - if self.ansible_version in ('2.5', '2.6'): - regex = r'controller \| MODULE FAILURE: This module is broken' - else: - # Ansible starting with 2.7 emits a different error message - # if a module exits without an exception or the ansible - # supplied methods. - regex = r'controller \| "msg": "New-style module did not ' \ - r'handle its own exit"' + regex = r'controller \| "msg": "New-style module did not ' \ + r'handle its own exit"' self.assertLogLine(regex, text) class TestZuulStream28(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): ansible_version = '2.8' + ansible_core_version = '2.8' def setUp(self): super().setUp() @@ -222,6 +200,16 @@ class TestZuulStream28(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): class TestZuulStream29(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): ansible_version = '2.9' + ansible_core_version = '2.9' + + def setUp(self): + super().setUp() + self._setUp() + + +class TestZuulStream5(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): + ansible_version = '5' + ansible_core_version = '2.12' def setUp(self): super().setUp() diff --git a/tests/unit/test_inventory.py b/tests/unit/test_inventory.py index 1f96bb5edc..76ee897434 100644 --- a/tests/unit/test_inventory.py +++ b/tests/unit/test_inventory.py @@ -201,6 +201,54 @@ class TestInventoryAutoPython(TestInventoryBase): self.executor_server.release() self.waitUntilSettled() + def test_auto_python_ansible29_inventory(self): + inventory = self._get_build_inventory('ansible-version29-inventory') + + all_nodes = ('ubuntu-xenial',) + self.assertIn('all', inventory) + self.assertIn('hosts', inventory['all']) + self.assertIn('vars', inventory['all']) + for node_name in all_nodes: + self.assertIn(node_name, inventory['all']['hosts']) + node_vars = inventory['all']['hosts'][node_name] + self.assertEqual( + 'auto', node_vars['ansible_python_interpreter']) + + self.assertIn('zuul', inventory['all']['vars']) + z_vars = inventory['all']['vars']['zuul'] + self.assertIn('executor', z_vars) + self.assertIn('src_root', z_vars['executor']) + self.assertIn('job', z_vars) + self.assertEqual(z_vars['job'], 'ansible-version29-inventory') + self.assertEqual(z_vars['message'], 'QQ==') + + self.executor_server.release() + self.waitUntilSettled() + + def test_auto_python_ansible5_inventory(self): + inventory = self._get_build_inventory('ansible-version5-inventory') + + all_nodes = ('ubuntu-xenial',) + self.assertIn('all', inventory) + self.assertIn('hosts', inventory['all']) + self.assertIn('vars', inventory['all']) + for node_name in all_nodes: + self.assertIn(node_name, inventory['all']['hosts']) + node_vars = inventory['all']['hosts'][node_name] + self.assertEqual( + 'auto', node_vars['ansible_python_interpreter']) + + self.assertIn('zuul', inventory['all']['vars']) + z_vars = inventory['all']['vars']['zuul'] + self.assertIn('executor', z_vars) + self.assertIn('src_root', z_vars['executor']) + self.assertIn('job', z_vars) + self.assertEqual(z_vars['job'], 'ansible-version5-inventory') + self.assertEqual(z_vars['message'], 'QQ==') + + self.executor_server.release() + self.waitUntilSettled() + class TestInventory(TestInventoryBase): diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py index 97c95e189c..35b24a687d 100644 --- a/tests/unit/test_v3.py +++ b/tests/unit/test_v3.py @@ -3733,6 +3733,10 @@ class TestAnsible29(AnsibleZuulTestCase, FunctionalAnsibleMixIn): ansible_version = '2.9' +class TestAnsible5(AnsibleZuulTestCase, FunctionalAnsibleMixIn): + ansible_version = '5' + + class TestPrePlaybooks(AnsibleZuulTestCase): # A temporary class to hold new tests while others are disabled @@ -7733,6 +7737,7 @@ class TestAnsibleVersion(AnsibleZuulTestCase): dict(name='ansible-default', result='SUCCESS', changes='1,1'), dict(name='ansible-28', result='SUCCESS', changes='1,1'), dict(name='ansible-29', result='SUCCESS', changes='1,1'), + dict(name='ansible-5', result='SUCCESS', changes='1,1'), ], ordered=False) @@ -7753,6 +7758,7 @@ class TestDefaultAnsibleVersion(AnsibleZuulTestCase): changes='1,1'), dict(name='ansible-28', result='SUCCESS', changes='1,1'), dict(name='ansible-29', result='SUCCESS', changes='1,1'), + dict(name='ansible-5', result='SUCCESS', changes='1,1'), ], ordered=False) diff --git a/zuul/ansible/2.8/library/command.py b/zuul/ansible/2.8/library/command.py deleted file mode 120000 index 9c76331696..0000000000 --- a/zuul/ansible/2.8/library/command.py +++ /dev/null @@ -1 +0,0 @@ -../../base/library/command.py \ No newline at end of file diff --git a/zuul/ansible/2.8/library/command.py b/zuul/ansible/2.8/library/command.py new file mode 100755 index 0000000000..f4511ad180 --- /dev/null +++ b/zuul/ansible/2.8/library/command.py @@ -0,0 +1,675 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2012, Michael DeHaan , and others +# Copyright: (c) 2016, Toshio Kuratomi +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + + +# flake8: noqa +# This file shares a significant chunk of code with an upstream ansible +# function, run_command. The goal is to not have to fork quite so much +# of that function, and discussing that design with upstream means we +# should keep the changes to substantive ones only. For that reason, this +# file is purposely not enforcing pep8, as making the function pep8 clean +# would remove our ability to easily have a discussion with our friends +# upstream + +DOCUMENTATION = ''' +--- +module: command +short_description: Executes a command on a remote node +version_added: historical +description: + - The C(command) module takes the command name followed by a list of space-delimited arguments. + - The given command will be executed on all selected nodes. It will not be + processed through the shell, so variables like C($HOME) and operations + like C("<"), C(">"), C("|"), C(";") and C("&") will not work (use the M(shell) + module if you need these features). + - For Windows targets, use the M(win_command) module instead. +options: + free_form: + description: + - The command module takes a free form command to run. There is no parameter actually named 'free form'. + See the examples! + required: yes + argv: + description: + - Allows the user to provide the command as a list vs. a string. Only the string or the list form can be + provided, not both. One or the other must be provided. + version_added: "2.6" + creates: + description: + - A filename or (since 2.0) glob pattern, when it already exists, this step will B(not) be run. + removes: + description: + - A filename or (since 2.0) glob pattern, when it does not exist, this step will B(not) be run. + version_added: "0.8" + chdir: + description: + - Change into this directory before running the command. + version_added: "0.6" + warn: + description: + - If command_warnings are on in ansible.cfg, do not warn about this particular line if set to C(no). + type: bool + default: 'yes' + version_added: "1.8" + stdin: + version_added: "2.4" + description: + - Set the stdin of the command directly to the specified value. +notes: + - If you want to run a command through the shell (say you are using C(<), C(>), C(|), etc), you actually want the M(shell) module instead. + Parsing shell metacharacters can lead to unexpected commands being executed if quoting is not done correctly so it is more secure to + use the C(command) module when possible. + - " C(creates), C(removes), and C(chdir) can be specified after the command. + For instance, if you only want to run a command if a certain file does not exist, use this." + - The C(executable) parameter is removed since version 2.4. If you have a need for this parameter, use the M(shell) module instead. + - For Windows targets, use the M(win_command) module instead. +author: + - Ansible Core Team + - Michael DeHaan +''' + +EXAMPLES = ''' +- name: return motd to registered var + command: cat /etc/motd + register: mymotd + +- name: Run the command if the specified file does not exist. + command: /usr/bin/make_database.sh arg1 arg2 + args: + creates: /path/to/database + +# You can also use the 'args' form to provide the options. +- name: This command will change the working directory to somedir/ and will only run when /path/to/database doesn't exist. + command: /usr/bin/make_database.sh arg1 arg2 + args: + chdir: somedir/ + creates: /path/to/database + +- name: use argv to send the command as a list. Be sure to leave command empty + command: + args: + argv: + - echo + - testing + +- name: safely use templated variable to run command. Always use the quote filter to avoid injection issues. + command: cat {{ myfile|quote }} + register: myoutput +''' + +RETURN = ''' +cmd: + description: the cmd that was run on the remote machine + returned: always + type: list + sample: + - echo + - hello +delta: + description: cmd end time - cmd start time + returned: always + type: string + sample: 0:00:00.001529 +end: + description: cmd end time + returned: always + type: string + sample: '2017-09-29 22:03:48.084657' +start: + description: cmd start time + returned: always + type: string + sample: '2017-09-29 22:03:48.083128' +''' + +import datetime +import glob +import os +import shlex + +from ansible.module_utils.basic import AnsibleModule + +# Imports needed for Zuul things +import re +import subprocess +import traceback +import threading +from ansible.module_utils.basic import heuristic_log_sanitize +from ansible.module_utils.six import ( + PY2, + PY3, + b, + binary_type, + string_types, + text_type, +) +from ansible.module_utils.six.moves import shlex_quote +from ansible.module_utils._text import to_native, to_bytes, to_text + + +LOG_STREAM_FILE = '/tmp/console-{log_uuid}.log' +PASSWD_ARG_RE = re.compile(r'^[-]{0,2}pass[-]?(word|wd)?') +# List to save stdout log lines in as we collect them +_log_lines = [] + + +class Console(object): + def __init__(self, log_uuid): + self.logfile_name = LOG_STREAM_FILE.format(log_uuid=log_uuid) + + def __enter__(self): + self.logfile = open(self.logfile_name, 'ab', buffering=0) + return self + + def __exit__(self, etype, value, tb): + self.logfile.close() + + def addLine(self, ln): + # Note this format with deliminator is "inspired" by the old + # Jenkins format but with microsecond resolution instead of + # millisecond. It is kept so log parsing/formatting remains + # consistent. + ts = str(datetime.datetime.now()).encode('utf-8') + if not isinstance(ln, bytes): + try: + ln = ln.encode('utf-8') + except Exception: + ln = repr(ln).encode('utf-8') + b'\n' + outln = b'%s | %s' % (ts, ln) + self.logfile.write(outln) + + +def follow(fd, log_uuid): + newline_warning = False + with Console(log_uuid) as console: + while True: + line = fd.readline() + if not line: + break + _log_lines.append(line) + if not line.endswith(b'\n'): + line += b'\n' + newline_warning = True + console.addLine(line) + if newline_warning: + console.addLine('[Zuul] No trailing newline\n') + + +# Taken from ansible/module_utils/basic.py ... forking the method for now +# so that we can dive in and figure out how to make appropriate hook points +def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, executable=None, data=None, binary_data=False, path_prefix=None, cwd=None, + use_unsafe_shell=False, prompt_regex=None, environ_update=None, umask=None, encoding='utf-8', errors='surrogate_or_strict'): + ''' + Execute a command, returns rc, stdout, and stderr. + + :arg args: is the command to run + * If args is a list, the command will be run with shell=False. + * If args is a string and use_unsafe_shell=False it will split args to a list and run with shell=False + * If args is a string and use_unsafe_shell=True it runs with shell=True. + :kw check_rc: Whether to call fail_json in case of non zero RC. + Default False + :kw close_fds: See documentation for subprocess.Popen(). Default True + :kw executable: See documentation for subprocess.Popen(). Default None + :kw data: If given, information to write to the stdin of the command + :kw binary_data: If False, append a newline to the data. Default False + :kw path_prefix: If given, additional path to find the command in. + This adds to the PATH environment vairable so helper commands in + the same directory can also be found + :kw cwd: If given, working directory to run the command inside + :kw use_unsafe_shell: See `args` parameter. Default False + :kw prompt_regex: Regex string (not a compiled regex) which can be + used to detect prompts in the stdout which would otherwise cause + the execution to hang (especially if no input data is specified) + :kw environ_update: dictionary to *update* os.environ with + :kw umask: Umask to be used when running the command. Default None + :kw encoding: Since we return native strings, on python3 we need to + know the encoding to use to transform from bytes to text. If you + want to always get bytes back, use encoding=None. The default is + "utf-8". This does not affect transformation of strings given as + args. + :kw errors: Since we return native strings, on python3 we need to + transform stdout and stderr from bytes to text. If the bytes are + undecodable in the ``encoding`` specified, then use this error + handler to deal with them. The default is ``surrogate_or_strict`` + which means that the bytes will be decoded using the + surrogateescape error handler if available (available on all + python3 versions we support) otherwise a UnicodeError traceback + will be raised. This does not affect transformations of strings + given as args. + :returns: A 3-tuple of return code (integer), stdout (native string), + and stderr (native string). On python2, stdout and stderr are both + byte strings. On python3, stdout and stderr are text strings converted + according to the encoding and errors parameters. If you want byte + strings on python3, use encoding=None to turn decoding to text off. + ''' + + if not isinstance(args, (list, binary_type, text_type)): + msg = "Argument 'args' to run_command must be list or string" + self.fail_json(rc=257, cmd=args, msg=msg) + + shell = False + if use_unsafe_shell: + + # stringify args for unsafe/direct shell usage + if isinstance(args, list): + args = " ".join([shlex_quote(x) for x in args]) + + # not set explicitly, check if set by controller + if executable: + args = [executable, '-c', args] + elif self._shell not in (None, '/bin/sh'): + args = [self._shell, '-c', args] + else: + shell = True + else: + # ensure args are a list + if isinstance(args, (binary_type, text_type)): + # On python2.6 and below, shlex has problems with text type + # On python3, shlex needs a text type. + if PY2: + args = to_bytes(args, errors='surrogate_or_strict') + elif PY3: + args = to_text(args, errors='surrogateescape') + args = shlex.split(args) + + # expand shellisms + args = [os.path.expanduser(os.path.expandvars(x)) for x in args if x is not None] + + prompt_re = None + if prompt_regex: + if isinstance(prompt_regex, text_type): + if PY3: + prompt_regex = to_bytes(prompt_regex, errors='surrogateescape') + elif PY2: + prompt_regex = to_bytes(prompt_regex, errors='surrogate_or_strict') + try: + prompt_re = re.compile(prompt_regex, re.MULTILINE) + except re.error: + self.fail_json(msg="invalid prompt regular expression given to run_command") + + rc = 0 + msg = None + st_in = None + + # Manipulate the environ we'll send to the new process + old_env_vals = {} + # We can set this from both an attribute and per call + for key, val in self.run_command_environ_update.items(): + old_env_vals[key] = os.environ.get(key, None) + os.environ[key] = val + if environ_update: + for key, val in environ_update.items(): + old_env_vals[key] = os.environ.get(key, None) + os.environ[key] = val + if path_prefix: + old_env_vals['PATH'] = os.environ['PATH'] + os.environ['PATH'] = "%s:%s" % (path_prefix, os.environ['PATH']) + + # If using test-module and explode, the remote lib path will resemble ... + # /tmp/test_module_scratch/debug_dir/ansible/module_utils/basic.py + # If using ansible or ansible-playbook with a remote system ... + # /tmp/ansible_vmweLQ/ansible_modlib.zip/ansible/module_utils/basic.py + + # Clean out python paths set by ansiballz + if 'PYTHONPATH' in os.environ: + pypaths = os.environ['PYTHONPATH'].split(':') + pypaths = [x for x in pypaths + if not x.endswith('/ansible_modlib.zip') and + not x.endswith('/debug_dir')] + os.environ['PYTHONPATH'] = ':'.join(pypaths) + if not os.environ['PYTHONPATH']: + del os.environ['PYTHONPATH'] + + # create a printable version of the command for use + # in reporting later, which strips out things like + # passwords from the args list + to_clean_args = args + if PY2: + if isinstance(args, text_type): + to_clean_args = to_bytes(args) + else: + if isinstance(args, binary_type): + to_clean_args = to_text(args) + if isinstance(args, (text_type, binary_type)): + to_clean_args = shlex.split(to_clean_args) + + clean_args = [] + is_passwd = False + for arg in (to_native(a) for a in to_clean_args): + if is_passwd: + is_passwd = False + clean_args.append('********') + continue + if PASSWD_ARG_RE.match(arg): + sep_idx = arg.find('=') + if sep_idx > -1: + clean_args.append('%s=********' % arg[:sep_idx]) + continue + else: + is_passwd = True + arg = heuristic_log_sanitize(arg, self.no_log_values) + clean_args.append(arg) + clean_args = ' '.join(shlex_quote(arg) for arg in clean_args) + + if data: + st_in = subprocess.PIPE + + # ZUUL: changed stderr to follow stdout + kwargs = dict( + executable=executable, + shell=shell, + close_fds=close_fds, + stdin=st_in, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + # store the pwd + prev_dir = os.getcwd() + + # make sure we're in the right working directory + if cwd and os.path.isdir(cwd): + cwd = os.path.abspath(os.path.expanduser(cwd)) + kwargs['cwd'] = cwd + try: + os.chdir(cwd) + except (OSError, IOError) as e: + self.fail_json(rc=e.errno, msg="Could not open %s, %s" % (cwd, to_native(e)), + exception=traceback.format_exc()) + + old_umask = None + if umask: + old_umask = os.umask(umask) + + t = None + fail_json_kwargs = None + + try: + if self._debug: + self.log('Executing: ' + clean_args) + + # ZUUL: Replaced the execution loop with the zuul_runner run function + + cmd = subprocess.Popen(args, **kwargs) + if self.no_log: + t = None + else: + t = threading.Thread(target=follow, args=(cmd.stdout, zuul_log_id)) + t.daemon = True + t.start() + + # ZUUL: Our log thread will catch the output so don't do that here. + + # # the communication logic here is essentially taken from that + # # of the _communicate() function in ssh.py + # + # stdout = b('') + # stderr = b('') + # + # # ZUUL: stderr follows stdout + # rpipes = [cmd.stdout] + + if data: + if not binary_data: + data += '\n' + if isinstance(data, text_type): + data = to_bytes(data) + cmd.stdin.write(data) + cmd.stdin.close() + + # while True: + # rfds, wfds, efds = select.select(rpipes, [], rpipes, 1) + # stdout += self._read_from_pipes(rpipes, rfds, cmd.stdout) + # + # # ZUUL: stderr follows stdout + # # stderr += self._read_from_pipes(rpipes, rfds, cmd.stderr) + # + # # if we're checking for prompts, do it now + # if prompt_re: + # if prompt_re.search(stdout) and not data: + # if encoding: + # stdout = to_native(stdout, encoding=encoding, errors=errors) + # else: + # stdout = stdout + # return (257, stdout, "A prompt was encountered while running a command, but no input data was specified") + # # only break out if no pipes are left to read or + # # the pipes are completely read and + # # the process is terminated + # if (not rpipes or not rfds) and cmd.poll() is not None: + # break + # # No pipes are left to read but process is not yet terminated + # # Only then it is safe to wait for the process to be finished + # # NOTE: Actually cmd.poll() is always None here if rpipes is empty + # elif not rpipes and cmd.poll() is None: + # cmd.wait() + # # The process is terminated. Since no pipes to read from are + # # left, there is no need to call select() again. + # break + + # ZUUL: If the console log follow thread *is* stuck in readline, + # we can't close stdout (attempting to do so raises an + # exception) , so this is disabled. + # cmd.stdout.close() + # cmd.stderr.close() + + rc = cmd.wait() + + # Give the thread that is writing the console log up to 10 seconds + # to catch up and exit. If it hasn't done so by then, it is very + # likely stuck in readline() because it spawed a child that is + # holding stdout or stderr open. + if t: + t.join(10) + with Console(zuul_log_id) as console: + if t.is_alive(): + console.addLine("[Zuul] standard output/error still open " + "after child exited") + # ZUUL: stdout and stderr are in the console log file + # ZUUL: return the saved log lines so we can ship them back + stdout = b('').join(_log_lines) + else: + stdout = b('') + stderr = b('') + + except (OSError, IOError) as e: + self.log("Error Executing CMD:%s Exception:%s" % (clean_args, to_native(e))) + # ZUUL: store fail_json_kwargs and fail later in finally + fail_json_kwargs = dict(rc=e.errno, msg=to_native(e), cmd=clean_args) + except Exception as e: + self.log("Error Executing CMD:%s Exception:%s" % (clean_args, to_native(traceback.format_exc()))) + # ZUUL: store fail_json_kwargs and fail later in finally + fail_json_kwargs = dict(rc=257, msg=to_native(e), exception=traceback.format_exc(), cmd=clean_args) + finally: + if t: + with Console(zuul_log_id) as console: + if t.is_alive(): + console.addLine("[Zuul] standard output/error still open " + "after child exited") + if fail_json_kwargs: + # we hit an exception and need to use the rc from + # fail_json_kwargs + rc = fail_json_kwargs['rc'] + + console.addLine("[Zuul] Task exit code: %s\n" % rc) + + if fail_json_kwargs: + self.fail_json(**fail_json_kwargs) + + # Restore env settings + for key, val in old_env_vals.items(): + if val is None: + del os.environ[key] + else: + os.environ[key] = val + + if old_umask: + os.umask(old_umask) + + if rc != 0 and check_rc: + msg = heuristic_log_sanitize(stderr.rstrip(), self.no_log_values) + self.fail_json(cmd=clean_args, rc=rc, stdout=stdout, stderr=stderr, msg=msg) + + # reset the pwd + os.chdir(prev_dir) + + if encoding is not None: + return (rc, to_native(stdout, encoding=encoding, errors=errors), + to_native(stderr, encoding=encoding, errors=errors)) + return (rc, stdout, stderr) + + +def check_command(module, commandline): + arguments = {'chown': 'owner', 'chmod': 'mode', 'chgrp': 'group', + 'ln': 'state=link', 'mkdir': 'state=directory', + 'rmdir': 'state=absent', 'rm': 'state=absent', 'touch': 'state=touch'} + commands = {'curl': 'get_url or uri', 'wget': 'get_url or uri', + 'svn': 'subversion', 'service': 'service', + 'mount': 'mount', 'rpm': 'yum, dnf or zypper', 'yum': 'yum', 'apt-get': 'apt', + 'tar': 'unarchive', 'unzip': 'unarchive', 'sed': 'replace, lineinfile or template', + 'dnf': 'dnf', 'zypper': 'zypper'} + become = ['sudo', 'su', 'pbrun', 'pfexec', 'runas', 'pmrun', 'machinectl'] + if isinstance(commandline, list): + command = commandline[0] + else: + command = commandline.split()[0] + command = os.path.basename(command) + + disable_suffix = "If you need to use command because {mod} is insufficient you can add" \ + " warn=False to this command task or set command_warnings=False in" \ + " ansible.cfg to get rid of this message." + substitutions = {'mod': None, 'cmd': command} + + if command in arguments: + msg = "Consider using the {mod} module with {subcmd} rather than running {cmd}. " + disable_suffix + substitutions['mod'] = 'file' + substitutions['subcmd'] = arguments[command] + module.warn(msg.format(**substitutions)) + + if command in commands: + msg = "Consider using the {mod} module rather than running {cmd}. " + disable_suffix + substitutions['mod'] = commands[command] + module.warn(msg.format(**substitutions)) + + if command in become: + module.warn("Consider using 'become', 'become_method', and 'become_user' rather than running %s" % (command,)) + + +def main(): + + # the command module is the one ansible module that does not take key=value args + # hence don't copy this one if you are looking to build others! + module = AnsibleModule( + argument_spec=dict( + _raw_params=dict(), + _uses_shell=dict(type='bool', default=False), + argv=dict(type='list'), + chdir=dict(type='path'), + executable=dict(), + creates=dict(type='path'), + removes=dict(type='path'), + # The default for this really comes from the action plugin + warn=dict(type='bool', default=True), + stdin=dict(required=False), + zuul_log_id=dict(type='str'), + ) + ) + shell = module.params['_uses_shell'] + chdir = module.params['chdir'] + executable = module.params['executable'] + args = module.params['_raw_params'] + argv = module.params['argv'] + creates = module.params['creates'] + removes = module.params['removes'] + warn = module.params['warn'] + stdin = module.params['stdin'] + zuul_log_id = module.params['zuul_log_id'] + + if not shell and executable: + module.warn("As of Ansible 2.4, the parameter 'executable' is no longer supported with the 'command' module. Not using '%s'." % executable) + executable = None + + if not zuul_log_id: + module.fail_json(rc=256, msg="zuul_log_id missing: %s" % module.params) + + if (not args or args.strip() == '') and not argv: + module.fail_json(rc=256, msg="no command given") + + if args and argv: + module.fail_json(rc=256, msg="only command or argv can be given, not both") + + if not shell and args: + args = shlex.split(args) + + args = args or argv + + if chdir: + chdir = os.path.abspath(chdir) + os.chdir(chdir) + + if creates: + # do not run the command if the line contains creates=filename + # and the filename already exists. This allows idempotence + # of command executions. + if glob.glob(creates): + module.exit_json( + cmd=args, + stdout="skipped, since %s exists" % creates, + changed=False, + rc=0 + ) + + if removes: + # do not run the command if the line contains removes=filename + # and the filename does not exist. This allows idempotence + # of command executions. + if not glob.glob(removes): + module.exit_json( + cmd=args, + stdout="skipped, since %s does not exist" % removes, + changed=False, + rc=0 + ) + + if warn: + check_command(module, args) + + startd = datetime.datetime.now() + + rc, out, err = zuul_run_command(module, args, zuul_log_id, executable=executable, use_unsafe_shell=shell, encoding=None, data=stdin) + + endd = datetime.datetime.now() + delta = endd - startd + + result = dict( + cmd=args, + stdout=out.rstrip(b"\r\n"), + stderr=err.rstrip(b"\r\n"), + rc=rc, + start=str(startd), + end=str(endd), + delta=str(delta), + changed=True, + zuul_log_id=zuul_log_id + ) + + if rc != 0: + module.fail_json(msg='non-zero return code', **result) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/zuul/ansible/2.9/library/command.py b/zuul/ansible/2.9/library/command.py deleted file mode 120000 index 9c76331696..0000000000 --- a/zuul/ansible/2.9/library/command.py +++ /dev/null @@ -1 +0,0 @@ -../../base/library/command.py \ No newline at end of file diff --git a/zuul/ansible/2.9/library/command.py b/zuul/ansible/2.9/library/command.py new file mode 100755 index 0000000000..f4511ad180 --- /dev/null +++ b/zuul/ansible/2.9/library/command.py @@ -0,0 +1,675 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2012, Michael DeHaan , and others +# Copyright: (c) 2016, Toshio Kuratomi +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + + +# flake8: noqa +# This file shares a significant chunk of code with an upstream ansible +# function, run_command. The goal is to not have to fork quite so much +# of that function, and discussing that design with upstream means we +# should keep the changes to substantive ones only. For that reason, this +# file is purposely not enforcing pep8, as making the function pep8 clean +# would remove our ability to easily have a discussion with our friends +# upstream + +DOCUMENTATION = ''' +--- +module: command +short_description: Executes a command on a remote node +version_added: historical +description: + - The C(command) module takes the command name followed by a list of space-delimited arguments. + - The given command will be executed on all selected nodes. It will not be + processed through the shell, so variables like C($HOME) and operations + like C("<"), C(">"), C("|"), C(";") and C("&") will not work (use the M(shell) + module if you need these features). + - For Windows targets, use the M(win_command) module instead. +options: + free_form: + description: + - The command module takes a free form command to run. There is no parameter actually named 'free form'. + See the examples! + required: yes + argv: + description: + - Allows the user to provide the command as a list vs. a string. Only the string or the list form can be + provided, not both. One or the other must be provided. + version_added: "2.6" + creates: + description: + - A filename or (since 2.0) glob pattern, when it already exists, this step will B(not) be run. + removes: + description: + - A filename or (since 2.0) glob pattern, when it does not exist, this step will B(not) be run. + version_added: "0.8" + chdir: + description: + - Change into this directory before running the command. + version_added: "0.6" + warn: + description: + - If command_warnings are on in ansible.cfg, do not warn about this particular line if set to C(no). + type: bool + default: 'yes' + version_added: "1.8" + stdin: + version_added: "2.4" + description: + - Set the stdin of the command directly to the specified value. +notes: + - If you want to run a command through the shell (say you are using C(<), C(>), C(|), etc), you actually want the M(shell) module instead. + Parsing shell metacharacters can lead to unexpected commands being executed if quoting is not done correctly so it is more secure to + use the C(command) module when possible. + - " C(creates), C(removes), and C(chdir) can be specified after the command. + For instance, if you only want to run a command if a certain file does not exist, use this." + - The C(executable) parameter is removed since version 2.4. If you have a need for this parameter, use the M(shell) module instead. + - For Windows targets, use the M(win_command) module instead. +author: + - Ansible Core Team + - Michael DeHaan +''' + +EXAMPLES = ''' +- name: return motd to registered var + command: cat /etc/motd + register: mymotd + +- name: Run the command if the specified file does not exist. + command: /usr/bin/make_database.sh arg1 arg2 + args: + creates: /path/to/database + +# You can also use the 'args' form to provide the options. +- name: This command will change the working directory to somedir/ and will only run when /path/to/database doesn't exist. + command: /usr/bin/make_database.sh arg1 arg2 + args: + chdir: somedir/ + creates: /path/to/database + +- name: use argv to send the command as a list. Be sure to leave command empty + command: + args: + argv: + - echo + - testing + +- name: safely use templated variable to run command. Always use the quote filter to avoid injection issues. + command: cat {{ myfile|quote }} + register: myoutput +''' + +RETURN = ''' +cmd: + description: the cmd that was run on the remote machine + returned: always + type: list + sample: + - echo + - hello +delta: + description: cmd end time - cmd start time + returned: always + type: string + sample: 0:00:00.001529 +end: + description: cmd end time + returned: always + type: string + sample: '2017-09-29 22:03:48.084657' +start: + description: cmd start time + returned: always + type: string + sample: '2017-09-29 22:03:48.083128' +''' + +import datetime +import glob +import os +import shlex + +from ansible.module_utils.basic import AnsibleModule + +# Imports needed for Zuul things +import re +import subprocess +import traceback +import threading +from ansible.module_utils.basic import heuristic_log_sanitize +from ansible.module_utils.six import ( + PY2, + PY3, + b, + binary_type, + string_types, + text_type, +) +from ansible.module_utils.six.moves import shlex_quote +from ansible.module_utils._text import to_native, to_bytes, to_text + + +LOG_STREAM_FILE = '/tmp/console-{log_uuid}.log' +PASSWD_ARG_RE = re.compile(r'^[-]{0,2}pass[-]?(word|wd)?') +# List to save stdout log lines in as we collect them +_log_lines = [] + + +class Console(object): + def __init__(self, log_uuid): + self.logfile_name = LOG_STREAM_FILE.format(log_uuid=log_uuid) + + def __enter__(self): + self.logfile = open(self.logfile_name, 'ab', buffering=0) + return self + + def __exit__(self, etype, value, tb): + self.logfile.close() + + def addLine(self, ln): + # Note this format with deliminator is "inspired" by the old + # Jenkins format but with microsecond resolution instead of + # millisecond. It is kept so log parsing/formatting remains + # consistent. + ts = str(datetime.datetime.now()).encode('utf-8') + if not isinstance(ln, bytes): + try: + ln = ln.encode('utf-8') + except Exception: + ln = repr(ln).encode('utf-8') + b'\n' + outln = b'%s | %s' % (ts, ln) + self.logfile.write(outln) + + +def follow(fd, log_uuid): + newline_warning = False + with Console(log_uuid) as console: + while True: + line = fd.readline() + if not line: + break + _log_lines.append(line) + if not line.endswith(b'\n'): + line += b'\n' + newline_warning = True + console.addLine(line) + if newline_warning: + console.addLine('[Zuul] No trailing newline\n') + + +# Taken from ansible/module_utils/basic.py ... forking the method for now +# so that we can dive in and figure out how to make appropriate hook points +def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, executable=None, data=None, binary_data=False, path_prefix=None, cwd=None, + use_unsafe_shell=False, prompt_regex=None, environ_update=None, umask=None, encoding='utf-8', errors='surrogate_or_strict'): + ''' + Execute a command, returns rc, stdout, and stderr. + + :arg args: is the command to run + * If args is a list, the command will be run with shell=False. + * If args is a string and use_unsafe_shell=False it will split args to a list and run with shell=False + * If args is a string and use_unsafe_shell=True it runs with shell=True. + :kw check_rc: Whether to call fail_json in case of non zero RC. + Default False + :kw close_fds: See documentation for subprocess.Popen(). Default True + :kw executable: See documentation for subprocess.Popen(). Default None + :kw data: If given, information to write to the stdin of the command + :kw binary_data: If False, append a newline to the data. Default False + :kw path_prefix: If given, additional path to find the command in. + This adds to the PATH environment vairable so helper commands in + the same directory can also be found + :kw cwd: If given, working directory to run the command inside + :kw use_unsafe_shell: See `args` parameter. Default False + :kw prompt_regex: Regex string (not a compiled regex) which can be + used to detect prompts in the stdout which would otherwise cause + the execution to hang (especially if no input data is specified) + :kw environ_update: dictionary to *update* os.environ with + :kw umask: Umask to be used when running the command. Default None + :kw encoding: Since we return native strings, on python3 we need to + know the encoding to use to transform from bytes to text. If you + want to always get bytes back, use encoding=None. The default is + "utf-8". This does not affect transformation of strings given as + args. + :kw errors: Since we return native strings, on python3 we need to + transform stdout and stderr from bytes to text. If the bytes are + undecodable in the ``encoding`` specified, then use this error + handler to deal with them. The default is ``surrogate_or_strict`` + which means that the bytes will be decoded using the + surrogateescape error handler if available (available on all + python3 versions we support) otherwise a UnicodeError traceback + will be raised. This does not affect transformations of strings + given as args. + :returns: A 3-tuple of return code (integer), stdout (native string), + and stderr (native string). On python2, stdout and stderr are both + byte strings. On python3, stdout and stderr are text strings converted + according to the encoding and errors parameters. If you want byte + strings on python3, use encoding=None to turn decoding to text off. + ''' + + if not isinstance(args, (list, binary_type, text_type)): + msg = "Argument 'args' to run_command must be list or string" + self.fail_json(rc=257, cmd=args, msg=msg) + + shell = False + if use_unsafe_shell: + + # stringify args for unsafe/direct shell usage + if isinstance(args, list): + args = " ".join([shlex_quote(x) for x in args]) + + # not set explicitly, check if set by controller + if executable: + args = [executable, '-c', args] + elif self._shell not in (None, '/bin/sh'): + args = [self._shell, '-c', args] + else: + shell = True + else: + # ensure args are a list + if isinstance(args, (binary_type, text_type)): + # On python2.6 and below, shlex has problems with text type + # On python3, shlex needs a text type. + if PY2: + args = to_bytes(args, errors='surrogate_or_strict') + elif PY3: + args = to_text(args, errors='surrogateescape') + args = shlex.split(args) + + # expand shellisms + args = [os.path.expanduser(os.path.expandvars(x)) for x in args if x is not None] + + prompt_re = None + if prompt_regex: + if isinstance(prompt_regex, text_type): + if PY3: + prompt_regex = to_bytes(prompt_regex, errors='surrogateescape') + elif PY2: + prompt_regex = to_bytes(prompt_regex, errors='surrogate_or_strict') + try: + prompt_re = re.compile(prompt_regex, re.MULTILINE) + except re.error: + self.fail_json(msg="invalid prompt regular expression given to run_command") + + rc = 0 + msg = None + st_in = None + + # Manipulate the environ we'll send to the new process + old_env_vals = {} + # We can set this from both an attribute and per call + for key, val in self.run_command_environ_update.items(): + old_env_vals[key] = os.environ.get(key, None) + os.environ[key] = val + if environ_update: + for key, val in environ_update.items(): + old_env_vals[key] = os.environ.get(key, None) + os.environ[key] = val + if path_prefix: + old_env_vals['PATH'] = os.environ['PATH'] + os.environ['PATH'] = "%s:%s" % (path_prefix, os.environ['PATH']) + + # If using test-module and explode, the remote lib path will resemble ... + # /tmp/test_module_scratch/debug_dir/ansible/module_utils/basic.py + # If using ansible or ansible-playbook with a remote system ... + # /tmp/ansible_vmweLQ/ansible_modlib.zip/ansible/module_utils/basic.py + + # Clean out python paths set by ansiballz + if 'PYTHONPATH' in os.environ: + pypaths = os.environ['PYTHONPATH'].split(':') + pypaths = [x for x in pypaths + if not x.endswith('/ansible_modlib.zip') and + not x.endswith('/debug_dir')] + os.environ['PYTHONPATH'] = ':'.join(pypaths) + if not os.environ['PYTHONPATH']: + del os.environ['PYTHONPATH'] + + # create a printable version of the command for use + # in reporting later, which strips out things like + # passwords from the args list + to_clean_args = args + if PY2: + if isinstance(args, text_type): + to_clean_args = to_bytes(args) + else: + if isinstance(args, binary_type): + to_clean_args = to_text(args) + if isinstance(args, (text_type, binary_type)): + to_clean_args = shlex.split(to_clean_args) + + clean_args = [] + is_passwd = False + for arg in (to_native(a) for a in to_clean_args): + if is_passwd: + is_passwd = False + clean_args.append('********') + continue + if PASSWD_ARG_RE.match(arg): + sep_idx = arg.find('=') + if sep_idx > -1: + clean_args.append('%s=********' % arg[:sep_idx]) + continue + else: + is_passwd = True + arg = heuristic_log_sanitize(arg, self.no_log_values) + clean_args.append(arg) + clean_args = ' '.join(shlex_quote(arg) for arg in clean_args) + + if data: + st_in = subprocess.PIPE + + # ZUUL: changed stderr to follow stdout + kwargs = dict( + executable=executable, + shell=shell, + close_fds=close_fds, + stdin=st_in, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + # store the pwd + prev_dir = os.getcwd() + + # make sure we're in the right working directory + if cwd and os.path.isdir(cwd): + cwd = os.path.abspath(os.path.expanduser(cwd)) + kwargs['cwd'] = cwd + try: + os.chdir(cwd) + except (OSError, IOError) as e: + self.fail_json(rc=e.errno, msg="Could not open %s, %s" % (cwd, to_native(e)), + exception=traceback.format_exc()) + + old_umask = None + if umask: + old_umask = os.umask(umask) + + t = None + fail_json_kwargs = None + + try: + if self._debug: + self.log('Executing: ' + clean_args) + + # ZUUL: Replaced the execution loop with the zuul_runner run function + + cmd = subprocess.Popen(args, **kwargs) + if self.no_log: + t = None + else: + t = threading.Thread(target=follow, args=(cmd.stdout, zuul_log_id)) + t.daemon = True + t.start() + + # ZUUL: Our log thread will catch the output so don't do that here. + + # # the communication logic here is essentially taken from that + # # of the _communicate() function in ssh.py + # + # stdout = b('') + # stderr = b('') + # + # # ZUUL: stderr follows stdout + # rpipes = [cmd.stdout] + + if data: + if not binary_data: + data += '\n' + if isinstance(data, text_type): + data = to_bytes(data) + cmd.stdin.write(data) + cmd.stdin.close() + + # while True: + # rfds, wfds, efds = select.select(rpipes, [], rpipes, 1) + # stdout += self._read_from_pipes(rpipes, rfds, cmd.stdout) + # + # # ZUUL: stderr follows stdout + # # stderr += self._read_from_pipes(rpipes, rfds, cmd.stderr) + # + # # if we're checking for prompts, do it now + # if prompt_re: + # if prompt_re.search(stdout) and not data: + # if encoding: + # stdout = to_native(stdout, encoding=encoding, errors=errors) + # else: + # stdout = stdout + # return (257, stdout, "A prompt was encountered while running a command, but no input data was specified") + # # only break out if no pipes are left to read or + # # the pipes are completely read and + # # the process is terminated + # if (not rpipes or not rfds) and cmd.poll() is not None: + # break + # # No pipes are left to read but process is not yet terminated + # # Only then it is safe to wait for the process to be finished + # # NOTE: Actually cmd.poll() is always None here if rpipes is empty + # elif not rpipes and cmd.poll() is None: + # cmd.wait() + # # The process is terminated. Since no pipes to read from are + # # left, there is no need to call select() again. + # break + + # ZUUL: If the console log follow thread *is* stuck in readline, + # we can't close stdout (attempting to do so raises an + # exception) , so this is disabled. + # cmd.stdout.close() + # cmd.stderr.close() + + rc = cmd.wait() + + # Give the thread that is writing the console log up to 10 seconds + # to catch up and exit. If it hasn't done so by then, it is very + # likely stuck in readline() because it spawed a child that is + # holding stdout or stderr open. + if t: + t.join(10) + with Console(zuul_log_id) as console: + if t.is_alive(): + console.addLine("[Zuul] standard output/error still open " + "after child exited") + # ZUUL: stdout and stderr are in the console log file + # ZUUL: return the saved log lines so we can ship them back + stdout = b('').join(_log_lines) + else: + stdout = b('') + stderr = b('') + + except (OSError, IOError) as e: + self.log("Error Executing CMD:%s Exception:%s" % (clean_args, to_native(e))) + # ZUUL: store fail_json_kwargs and fail later in finally + fail_json_kwargs = dict(rc=e.errno, msg=to_native(e), cmd=clean_args) + except Exception as e: + self.log("Error Executing CMD:%s Exception:%s" % (clean_args, to_native(traceback.format_exc()))) + # ZUUL: store fail_json_kwargs and fail later in finally + fail_json_kwargs = dict(rc=257, msg=to_native(e), exception=traceback.format_exc(), cmd=clean_args) + finally: + if t: + with Console(zuul_log_id) as console: + if t.is_alive(): + console.addLine("[Zuul] standard output/error still open " + "after child exited") + if fail_json_kwargs: + # we hit an exception and need to use the rc from + # fail_json_kwargs + rc = fail_json_kwargs['rc'] + + console.addLine("[Zuul] Task exit code: %s\n" % rc) + + if fail_json_kwargs: + self.fail_json(**fail_json_kwargs) + + # Restore env settings + for key, val in old_env_vals.items(): + if val is None: + del os.environ[key] + else: + os.environ[key] = val + + if old_umask: + os.umask(old_umask) + + if rc != 0 and check_rc: + msg = heuristic_log_sanitize(stderr.rstrip(), self.no_log_values) + self.fail_json(cmd=clean_args, rc=rc, stdout=stdout, stderr=stderr, msg=msg) + + # reset the pwd + os.chdir(prev_dir) + + if encoding is not None: + return (rc, to_native(stdout, encoding=encoding, errors=errors), + to_native(stderr, encoding=encoding, errors=errors)) + return (rc, stdout, stderr) + + +def check_command(module, commandline): + arguments = {'chown': 'owner', 'chmod': 'mode', 'chgrp': 'group', + 'ln': 'state=link', 'mkdir': 'state=directory', + 'rmdir': 'state=absent', 'rm': 'state=absent', 'touch': 'state=touch'} + commands = {'curl': 'get_url or uri', 'wget': 'get_url or uri', + 'svn': 'subversion', 'service': 'service', + 'mount': 'mount', 'rpm': 'yum, dnf or zypper', 'yum': 'yum', 'apt-get': 'apt', + 'tar': 'unarchive', 'unzip': 'unarchive', 'sed': 'replace, lineinfile or template', + 'dnf': 'dnf', 'zypper': 'zypper'} + become = ['sudo', 'su', 'pbrun', 'pfexec', 'runas', 'pmrun', 'machinectl'] + if isinstance(commandline, list): + command = commandline[0] + else: + command = commandline.split()[0] + command = os.path.basename(command) + + disable_suffix = "If you need to use command because {mod} is insufficient you can add" \ + " warn=False to this command task or set command_warnings=False in" \ + " ansible.cfg to get rid of this message." + substitutions = {'mod': None, 'cmd': command} + + if command in arguments: + msg = "Consider using the {mod} module with {subcmd} rather than running {cmd}. " + disable_suffix + substitutions['mod'] = 'file' + substitutions['subcmd'] = arguments[command] + module.warn(msg.format(**substitutions)) + + if command in commands: + msg = "Consider using the {mod} module rather than running {cmd}. " + disable_suffix + substitutions['mod'] = commands[command] + module.warn(msg.format(**substitutions)) + + if command in become: + module.warn("Consider using 'become', 'become_method', and 'become_user' rather than running %s" % (command,)) + + +def main(): + + # the command module is the one ansible module that does not take key=value args + # hence don't copy this one if you are looking to build others! + module = AnsibleModule( + argument_spec=dict( + _raw_params=dict(), + _uses_shell=dict(type='bool', default=False), + argv=dict(type='list'), + chdir=dict(type='path'), + executable=dict(), + creates=dict(type='path'), + removes=dict(type='path'), + # The default for this really comes from the action plugin + warn=dict(type='bool', default=True), + stdin=dict(required=False), + zuul_log_id=dict(type='str'), + ) + ) + shell = module.params['_uses_shell'] + chdir = module.params['chdir'] + executable = module.params['executable'] + args = module.params['_raw_params'] + argv = module.params['argv'] + creates = module.params['creates'] + removes = module.params['removes'] + warn = module.params['warn'] + stdin = module.params['stdin'] + zuul_log_id = module.params['zuul_log_id'] + + if not shell and executable: + module.warn("As of Ansible 2.4, the parameter 'executable' is no longer supported with the 'command' module. Not using '%s'." % executable) + executable = None + + if not zuul_log_id: + module.fail_json(rc=256, msg="zuul_log_id missing: %s" % module.params) + + if (not args or args.strip() == '') and not argv: + module.fail_json(rc=256, msg="no command given") + + if args and argv: + module.fail_json(rc=256, msg="only command or argv can be given, not both") + + if not shell and args: + args = shlex.split(args) + + args = args or argv + + if chdir: + chdir = os.path.abspath(chdir) + os.chdir(chdir) + + if creates: + # do not run the command if the line contains creates=filename + # and the filename already exists. This allows idempotence + # of command executions. + if glob.glob(creates): + module.exit_json( + cmd=args, + stdout="skipped, since %s exists" % creates, + changed=False, + rc=0 + ) + + if removes: + # do not run the command if the line contains removes=filename + # and the filename does not exist. This allows idempotence + # of command executions. + if not glob.glob(removes): + module.exit_json( + cmd=args, + stdout="skipped, since %s does not exist" % removes, + changed=False, + rc=0 + ) + + if warn: + check_command(module, args) + + startd = datetime.datetime.now() + + rc, out, err = zuul_run_command(module, args, zuul_log_id, executable=executable, use_unsafe_shell=shell, encoding=None, data=stdin) + + endd = datetime.datetime.now() + delta = endd - startd + + result = dict( + cmd=args, + stdout=out.rstrip(b"\r\n"), + stderr=err.rstrip(b"\r\n"), + rc=rc, + start=str(startd), + end=str(endd), + delta=str(delta), + changed=True, + zuul_log_id=zuul_log_id + ) + + if rc != 0: + module.fail_json(msg='non-zero return code', **result) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/zuul/ansible/5/action/__init__.py b/zuul/ansible/5/action/__init__.py new file mode 120000 index 0000000000..4048e7ac12 --- /dev/null +++ b/zuul/ansible/5/action/__init__.py @@ -0,0 +1 @@ +../../base/action/__init__.py \ No newline at end of file diff --git a/zuul/ansible/5/action/command.py b/zuul/ansible/5/action/command.py new file mode 120000 index 0000000000..56c6b636fa --- /dev/null +++ b/zuul/ansible/5/action/command.py @@ -0,0 +1 @@ +../../base/action/command.py \ No newline at end of file diff --git a/zuul/ansible/5/action/command.pyi b/zuul/ansible/5/action/command.pyi new file mode 120000 index 0000000000..a003281caf --- /dev/null +++ b/zuul/ansible/5/action/command.pyi @@ -0,0 +1 @@ +../../base/action/command.pyi \ No newline at end of file diff --git a/zuul/ansible/5/action/zuul_return.py b/zuul/ansible/5/action/zuul_return.py new file mode 120000 index 0000000000..83c2fc619e --- /dev/null +++ b/zuul/ansible/5/action/zuul_return.py @@ -0,0 +1 @@ +../../base/action/zuul_return.py \ No newline at end of file diff --git a/zuul/ansible/5/callback/__init__.py b/zuul/ansible/5/callback/__init__.py new file mode 120000 index 0000000000..00b9743884 --- /dev/null +++ b/zuul/ansible/5/callback/__init__.py @@ -0,0 +1 @@ +../../base/callback/__init__.py \ No newline at end of file diff --git a/zuul/ansible/5/callback/zuul_json.py b/zuul/ansible/5/callback/zuul_json.py new file mode 120000 index 0000000000..b0a07779ba --- /dev/null +++ b/zuul/ansible/5/callback/zuul_json.py @@ -0,0 +1 @@ +../../base/callback/zuul_json.py \ No newline at end of file diff --git a/zuul/ansible/5/callback/zuul_stream.py b/zuul/ansible/5/callback/zuul_stream.py new file mode 120000 index 0000000000..f75561bf46 --- /dev/null +++ b/zuul/ansible/5/callback/zuul_stream.py @@ -0,0 +1 @@ +../../base/callback/zuul_stream.py \ No newline at end of file diff --git a/zuul/ansible/5/callback/zuul_unreachable.py b/zuul/ansible/5/callback/zuul_unreachable.py new file mode 120000 index 0000000000..205baca6fe --- /dev/null +++ b/zuul/ansible/5/callback/zuul_unreachable.py @@ -0,0 +1 @@ +../../base/callback/zuul_unreachable.py \ No newline at end of file diff --git a/zuul/ansible/5/filter/__init__.py b/zuul/ansible/5/filter/__init__.py new file mode 120000 index 0000000000..f80a4da617 --- /dev/null +++ b/zuul/ansible/5/filter/__init__.py @@ -0,0 +1 @@ +../../base/filter/__init__.py \ No newline at end of file diff --git a/zuul/ansible/5/filter/zuul_filters.py b/zuul/ansible/5/filter/zuul_filters.py new file mode 120000 index 0000000000..d406e5fe63 --- /dev/null +++ b/zuul/ansible/5/filter/zuul_filters.py @@ -0,0 +1 @@ +../../base/filter/zuul_filters.py \ No newline at end of file diff --git a/zuul/ansible/5/library/__init__.py b/zuul/ansible/5/library/__init__.py new file mode 120000 index 0000000000..0b68ce0f48 --- /dev/null +++ b/zuul/ansible/5/library/__init__.py @@ -0,0 +1 @@ +../../base/library/__init__.py \ No newline at end of file diff --git a/zuul/ansible/5/library/command.py b/zuul/ansible/5/library/command.py new file mode 120000 index 0000000000..9c76331696 --- /dev/null +++ b/zuul/ansible/5/library/command.py @@ -0,0 +1 @@ +../../base/library/command.py \ No newline at end of file diff --git a/zuul/ansible/5/library/zuul_console.py b/zuul/ansible/5/library/zuul_console.py new file mode 120000 index 0000000000..7c905e0f9c --- /dev/null +++ b/zuul/ansible/5/library/zuul_console.py @@ -0,0 +1 @@ +../../base/library/zuul_console.py \ No newline at end of file diff --git a/zuul/ansible/5/logconfig.py b/zuul/ansible/5/logconfig.py new file mode 120000 index 0000000000..767cb2e81f --- /dev/null +++ b/zuul/ansible/5/logconfig.py @@ -0,0 +1 @@ +../logconfig.py \ No newline at end of file diff --git a/zuul/ansible/5/paths.py b/zuul/ansible/5/paths.py new file mode 120000 index 0000000000..dbdb1858ec --- /dev/null +++ b/zuul/ansible/5/paths.py @@ -0,0 +1 @@ +../paths.py \ No newline at end of file diff --git a/zuul/ansible/base/library/command.py b/zuul/ansible/base/library/command.py index f4511ad180..ced4397479 100755 --- a/zuul/ansible/base/library/command.py +++ b/zuul/ansible/base/library/command.py @@ -3,18 +3,12 @@ # Copyright: (c) 2012, Michael DeHaan , and others # Copyright: (c) 2016, Toshio Kuratomi -# # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function __metaclass__ = type -ANSIBLE_METADATA = {'metadata_version': '1.1', - 'status': ['stableinterface'], - 'supported_by': 'core'} - - # flake8: noqa # This file shares a significant chunk of code with an upstream ansible # function, run_command. The goal is to not have to fork quite so much @@ -24,115 +18,215 @@ ANSIBLE_METADATA = {'metadata_version': '1.1', # would remove our ability to easily have a discussion with our friends # upstream -DOCUMENTATION = ''' +DOCUMENTATION = r''' --- module: command -short_description: Executes a command on a remote node +short_description: Execute commands on targets version_added: historical description: - The C(command) module takes the command name followed by a list of space-delimited arguments. - - The given command will be executed on all selected nodes. It will not be - processed through the shell, so variables like C($HOME) and operations - like C("<"), C(">"), C("|"), C(";") and C("&") will not work (use the M(shell) - module if you need these features). - - For Windows targets, use the M(win_command) module instead. + - The given command will be executed on all selected nodes. + - The command(s) will not be + processed through the shell, so variables like C($HOSTNAME) and operations + like C("*"), C("<"), C(">"), C("|"), C(";") and C("&") will not work. + Use the M(ansible.builtin.shell) module if you need these features. + - To create C(command) tasks that are easier to read than the ones using space-delimited + arguments, pass parameters using the C(args) L(task keyword,../reference_appendices/playbooks_keywords.html#task) + or use C(cmd) parameter. + - Either a free form command or C(cmd) parameter is required, see the examples. + - For Windows targets, use the M(ansible.windows.win_command) module instead. +extends_documentation_fragment: + - action_common_attributes + - action_common_attributes.raw +attributes: + check_mode: + details: while the command itself is arbitrary and cannot be subject to the check mode semantics it adds C(creates)/C(removes) options as a workaround + support: partial + diff_mode: + support: none + platform: + support: full + platforms: posix + raw: + support: full options: free_form: description: - - The command module takes a free form command to run. There is no parameter actually named 'free form'. - See the examples! - required: yes - argv: + - The command module takes a free form string as a command to run. + - There is no actual parameter named 'free form'. + cmd: + type: str description: - - Allows the user to provide the command as a list vs. a string. Only the string or the list form can be - provided, not both. One or the other must be provided. + - The command to run. + argv: + type: list + elements: str + description: + - Passes the command as a list rather than a string. + - Use C(argv) to avoid quoting values that would otherwise be interpreted incorrectly (for example "user name"). + - Only the string (free form) or the list (argv) form can be provided, not both. One or the other must be provided. version_added: "2.6" creates: + type: path description: - - A filename or (since 2.0) glob pattern, when it already exists, this step will B(not) be run. + - A filename or (since 2.0) glob pattern. If a matching file already exists, this step B(will not) be run. + - This is checked before I(removes) is checked. removes: + type: path description: - - A filename or (since 2.0) glob pattern, when it does not exist, this step will B(not) be run. + - A filename or (since 2.0) glob pattern. If a matching file exists, this step B(will) be run. + - This is checked after I(creates) is checked. version_added: "0.8" chdir: + type: path description: - Change into this directory before running the command. version_added: "0.6" warn: description: - - If command_warnings are on in ansible.cfg, do not warn about this particular line if set to C(no). + - (deprecated) Enable or disable task warnings. + - This feature is deprecated and will be removed in 2.14. + - As of version 2.11, this option is now disabled by default. type: bool - default: 'yes' + default: no version_added: "1.8" stdin: - version_added: "2.4" description: - Set the stdin of the command directly to the specified value. + type: str + version_added: "2.4" + stdin_add_newline: + type: bool + default: yes + description: + - If set to C(yes), append a newline to stdin data. + version_added: "2.8" + strip_empty_ends: + description: + - Strip empty lines from the end of stdout/stderr in result. + version_added: "2.8" + type: bool + default: yes notes: - - If you want to run a command through the shell (say you are using C(<), C(>), C(|), etc), you actually want the M(shell) module instead. + - If you want to run a command through the shell (say you are using C(<), C(>), C(|), and so on), + you actually want the M(ansible.builtin.shell) module instead. Parsing shell metacharacters can lead to unexpected commands being executed if quoting is not done correctly so it is more secure to use the C(command) module when possible. - - " C(creates), C(removes), and C(chdir) can be specified after the command. - For instance, if you only want to run a command if a certain file does not exist, use this." - - The C(executable) parameter is removed since version 2.4. If you have a need for this parameter, use the M(shell) module instead. - - For Windows targets, use the M(win_command) module instead. + - C(creates), C(removes), and C(chdir) can be specified after the command. + For instance, if you only want to run a command if a certain file does not exist, use this. + - Check mode is supported when passing C(creates) or C(removes). If running in check mode and either of these are specified, the module will + check for the existence of the file and report the correct changed status. If these are not supplied, the task will be skipped. + - The C(executable) parameter is removed since version 2.4. If you have a need for this parameter, use the M(ansible.builtin.shell) module instead. + - For Windows targets, use the M(ansible.windows.win_command) module instead. + - For rebooting systems, use the M(ansible.builtin.reboot) or M(ansible.windows.win_reboot) module. +seealso: +- module: ansible.builtin.raw +- module: ansible.builtin.script +- module: ansible.builtin.shell +- module: ansible.windows.win_command author: - Ansible Core Team - Michael DeHaan ''' -EXAMPLES = ''' -- name: return motd to registered var - command: cat /etc/motd +EXAMPLES = r''' +- name: Return motd to registered var + ansible.builtin.command: cat /etc/motd register: mymotd -- name: Run the command if the specified file does not exist. - command: /usr/bin/make_database.sh arg1 arg2 +# free-form (string) arguments, all arguments on one line +- name: Run command if /path/to/database does not exist (without 'args') + ansible.builtin.command: /usr/bin/make_database.sh db_user db_name creates=/path/to/database + +# free-form (string) arguments, some arguments on separate lines with the 'args' keyword +# 'args' is a task keyword, passed at the same level as the module +- name: Run command if /path/to/database does not exist (with 'args' keyword) + ansible.builtin.command: /usr/bin/make_database.sh db_user db_name args: creates: /path/to/database -# You can also use the 'args' form to provide the options. -- name: This command will change the working directory to somedir/ and will only run when /path/to/database doesn't exist. - command: /usr/bin/make_database.sh arg1 arg2 +# 'cmd' is module parameter +- name: Run command if /path/to/database does not exist (with 'cmd' parameter) + ansible.builtin.command: + cmd: /usr/bin/make_database.sh db_user db_name + creates: /path/to/database + +- name: Change the working directory to somedir/ and run the command as db_owner if /path/to/database does not exist + ansible.builtin.command: /usr/bin/make_database.sh db_user db_name + become: yes + become_user: db_owner args: chdir: somedir/ creates: /path/to/database -- name: use argv to send the command as a list. Be sure to leave command empty - command: - args: +# argv (list) arguments, each argument on a separate line, 'args' keyword not necessary +# 'argv' is a parameter, indented one level from the module +- name: Use 'argv' to send a command as a list - leave 'command' empty + ansible.builtin.command: argv: - - echo - - testing + - /usr/bin/make_database.sh + - Username with whitespace + - dbname with whitespace + creates: /path/to/database -- name: safely use templated variable to run command. Always use the quote filter to avoid injection issues. - command: cat {{ myfile|quote }} +- name: Safely use templated variable to run command. Always use the quote filter to avoid injection issues + ansible.builtin.command: cat {{ myfile|quote }} register: myoutput ''' -RETURN = ''' +RETURN = r''' +msg: + description: changed + returned: always + type: bool + sample: True +start: + description: The command execution start time. + returned: always + type: str + sample: '2017-09-29 22:03:48.083128' +end: + description: The command execution end time. + returned: always + type: str + sample: '2017-09-29 22:03:48.084657' +delta: + description: The command execution delta time. + returned: always + type: str + sample: '0:00:00.001529' +stdout: + description: The command standard output. + returned: always + type: str + sample: 'Clustering node rabbit@slave1 with rabbit@master …' +stderr: + description: The command standard error. + returned: always + type: str + sample: 'ls cannot access foo: No such file or directory' cmd: - description: the cmd that was run on the remote machine + description: The command executed by the task. returned: always type: list sample: - echo - hello -delta: - description: cmd end time - cmd start time +rc: + description: The command return code (0 means success). returned: always - type: string - sample: 0:00:00.001529 -end: - description: cmd end time + type: int + sample: 0 +stdout_lines: + description: The command standard output split in lines. returned: always - type: string - sample: '2017-09-29 22:03:48.084657' -start: - description: cmd start time + type: list + sample: [u'Clustering node rabbit@slave1 with rabbit@master …'] +stderr_lines: + description: The command standard error split in lines. returned: always - type: string - sample: '2017-09-29 22:03:48.083128' + type: list + sample: [u'ls cannot access foo: No such file or directory', u'ls …'] ''' import datetime @@ -141,6 +235,8 @@ import os import shlex from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native, to_bytes, to_text +from ansible.module_utils.common.collections import is_iterable # Imports needed for Zuul things import re @@ -157,7 +253,6 @@ from ansible.module_utils.six import ( text_type, ) from ansible.module_utils.six.moves import shlex_quote -from ansible.module_utils._text import to_native, to_bytes, to_text LOG_STREAM_FILE = '/tmp/console-{log_uuid}.log' @@ -211,7 +306,8 @@ def follow(fd, log_uuid): # Taken from ansible/module_utils/basic.py ... forking the method for now # so that we can dive in and figure out how to make appropriate hook points def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, executable=None, data=None, binary_data=False, path_prefix=None, cwd=None, - use_unsafe_shell=False, prompt_regex=None, environ_update=None, umask=None, encoding='utf-8', errors='surrogate_or_strict'): + use_unsafe_shell=False, prompt_regex=None, environ_update=None, umask=None, encoding='utf-8', errors='surrogate_or_strict', + expand_user_and_vars=True, pass_fds=None, before_communicate_callback=None, ignore_invalid_cwd=True): ''' Execute a command, returns rc, stdout, and stderr. @@ -226,14 +322,14 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex :kw data: If given, information to write to the stdin of the command :kw binary_data: If False, append a newline to the data. Default False :kw path_prefix: If given, additional path to find the command in. - This adds to the PATH environment vairable so helper commands in + This adds to the PATH environment variable so helper commands in the same directory can also be found :kw cwd: If given, working directory to run the command inside :kw use_unsafe_shell: See `args` parameter. Default False :kw prompt_regex: Regex string (not a compiled regex) which can be used to detect prompts in the stdout which would otherwise cause the execution to hang (especially if no input data is specified) - :kw environ_update: dictionary to *update* os.environ with + :kw environ_update: dictionary to *update* environ variables with :kw umask: Umask to be used when running the command. Default None :kw encoding: Since we return native strings, on python3 we need to know the encoding to use to transform from bytes to text. If you @@ -249,12 +345,30 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex python3 versions we support) otherwise a UnicodeError traceback will be raised. This does not affect transformations of strings given as args. + :kw expand_user_and_vars: When ``use_unsafe_shell=False`` this argument + dictates whether ``~`` is expanded in paths and environment variables + are expanded before running the command. When ``True`` a string such as + ``$SHELL`` will be expanded regardless of escaping. When ``False`` and + ``use_unsafe_shell=False`` no path or variable expansion will be done. + :kw pass_fds: When running on Python 3 this argument + dictates which file descriptors should be passed + to an underlying ``Popen`` constructor. On Python 2, this will + set ``close_fds`` to False. + :kw before_communicate_callback: This function will be called + after ``Popen`` object will be created + but before communicating to the process. + (``Popen`` object will be passed to callback as a first argument) + :kw ignore_invalid_cwd: This flag indicates whether an invalid ``cwd`` + (non-existent or not a directory) should be ignored or should raise + an exception. :returns: A 3-tuple of return code (integer), stdout (native string), and stderr (native string). On python2, stdout and stderr are both byte strings. On python3, stdout and stderr are text strings converted according to the encoding and errors parameters. If you want byte strings on python3, use encoding=None to turn decoding to text off. ''' + # used by clean args later on + self._clean = None if not isinstance(args, (list, binary_type, text_type)): msg = "Argument 'args' to run_command must be list or string" @@ -265,13 +379,16 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex # stringify args for unsafe/direct shell usage if isinstance(args, list): - args = " ".join([shlex_quote(x) for x in args]) + args = b" ".join([to_bytes(shlex_quote(x), errors='surrogate_or_strict') for x in args]) + else: + args = to_bytes(args, errors='surrogate_or_strict') # not set explicitly, check if set by controller if executable: - args = [executable, '-c', args] + executable = to_bytes(executable, errors='surrogate_or_strict') + args = [executable, b'-c', args] elif self._shell not in (None, '/bin/sh'): - args = [self._shell, '-c', args] + args = [to_bytes(self._shell, errors='surrogate_or_strict'), b'-c', args] else: shell = True else: @@ -285,8 +402,11 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex args = to_text(args, errors='surrogateescape') args = shlex.split(args) - # expand shellisms - args = [os.path.expanduser(os.path.expandvars(x)) for x in args if x is not None] + # expand ``~`` in paths, and all environment vars + if expand_user_and_vars: + args = [to_bytes(os.path.expanduser(os.path.expandvars(x)), errors='surrogate_or_strict') for x in args if x is not None] + else: + args = [to_bytes(x, errors='surrogate_or_strict') for x in args if x is not None] prompt_re = None if prompt_regex: @@ -304,69 +424,39 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex msg = None st_in = None - # Manipulate the environ we'll send to the new process - old_env_vals = {} + env = os.environ.copy() # We can set this from both an attribute and per call - for key, val in self.run_command_environ_update.items(): - old_env_vals[key] = os.environ.get(key, None) - os.environ[key] = val - if environ_update: - for key, val in environ_update.items(): - old_env_vals[key] = os.environ.get(key, None) - os.environ[key] = val + env.update(self.run_command_environ_update or {}) + env.update(environ_update or {}) if path_prefix: - old_env_vals['PATH'] = os.environ['PATH'] - os.environ['PATH'] = "%s:%s" % (path_prefix, os.environ['PATH']) + path = env.get('PATH', '') + if path: + env['PATH'] = "%s:%s" % (path_prefix, path) + else: + env['PATH'] = path_prefix - # If using test-module and explode, the remote lib path will resemble ... + # If using test-module.py and explode, the remote lib path will resemble: # /tmp/test_module_scratch/debug_dir/ansible/module_utils/basic.py - # If using ansible or ansible-playbook with a remote system ... + # If using ansible or ansible-playbook with a remote system: # /tmp/ansible_vmweLQ/ansible_modlib.zip/ansible/module_utils/basic.py # Clean out python paths set by ansiballz - if 'PYTHONPATH' in os.environ: - pypaths = os.environ['PYTHONPATH'].split(':') - pypaths = [x for x in pypaths - if not x.endswith('/ansible_modlib.zip') and + if 'PYTHONPATH' in env: + pypaths = [x for x in env['PYTHONPATH'].split(':') + if x and + not x.endswith('/ansible_modlib.zip') and not x.endswith('/debug_dir')] - os.environ['PYTHONPATH'] = ':'.join(pypaths) - if not os.environ['PYTHONPATH']: - del os.environ['PYTHONPATH'] - - # create a printable version of the command for use - # in reporting later, which strips out things like - # passwords from the args list - to_clean_args = args - if PY2: - if isinstance(args, text_type): - to_clean_args = to_bytes(args) - else: - if isinstance(args, binary_type): - to_clean_args = to_text(args) - if isinstance(args, (text_type, binary_type)): - to_clean_args = shlex.split(to_clean_args) - - clean_args = [] - is_passwd = False - for arg in (to_native(a) for a in to_clean_args): - if is_passwd: - is_passwd = False - clean_args.append('********') - continue - if PASSWD_ARG_RE.match(arg): - sep_idx = arg.find('=') - if sep_idx > -1: - clean_args.append('%s=********' % arg[:sep_idx]) - continue - else: - is_passwd = True - arg = heuristic_log_sanitize(arg, self.no_log_values) - clean_args.append(arg) - clean_args = ' '.join(shlex_quote(arg) for arg in clean_args) + if pypaths and any(pypaths): + env['PYTHONPATH'] = ':'.join(pypaths) if data: st_in = subprocess.PIPE + def preexec(): + self._restore_signal_handlers() + if umask: + os.umask(umask) + # ZUUL: changed stderr to follow stdout kwargs = dict( executable=executable, @@ -375,35 +465,35 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex stdin=st_in, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + preexec_fn=preexec, + env=env, ) - - # store the pwd - prev_dir = os.getcwd() + if PY3 and pass_fds: + kwargs["pass_fds"] = pass_fds + elif PY2 and pass_fds: + kwargs['close_fds'] = False # make sure we're in the right working directory - if cwd and os.path.isdir(cwd): - cwd = os.path.abspath(os.path.expanduser(cwd)) - kwargs['cwd'] = cwd - try: - os.chdir(cwd) - except (OSError, IOError) as e: - self.fail_json(rc=e.errno, msg="Could not open %s, %s" % (cwd, to_native(e)), - exception=traceback.format_exc()) - - old_umask = None - if umask: - old_umask = os.umask(umask) + if cwd: + cwd = to_bytes(os.path.abspath(os.path.expanduser(cwd)), errors='surrogate_or_strict') + if os.path.isdir(cwd): + kwargs['cwd'] = cwd + elif not ignore_invalid_cwd: + self.fail_json(msg="Provided cwd is not a valid directory: %s" % cwd) t = None fail_json_kwargs = None try: if self._debug: - self.log('Executing: ' + clean_args) + self.log('Executing: ' + self._clean_args(args)) # ZUUL: Replaced the execution loop with the zuul_runner run function cmd = subprocess.Popen(args, **kwargs) + if before_communicate_callback: + before_communicate_callback(cmd) + if self.no_log: t = None else: @@ -413,15 +503,6 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex # ZUUL: Our log thread will catch the output so don't do that here. - # # the communication logic here is essentially taken from that - # # of the _communicate() function in ssh.py - # - # stdout = b('') - # stderr = b('') - # - # # ZUUL: stderr follows stdout - # rpipes = [cmd.stdout] - if data: if not binary_data: data += '\n' @@ -430,35 +511,6 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex cmd.stdin.write(data) cmd.stdin.close() - # while True: - # rfds, wfds, efds = select.select(rpipes, [], rpipes, 1) - # stdout += self._read_from_pipes(rpipes, rfds, cmd.stdout) - # - # # ZUUL: stderr follows stdout - # # stderr += self._read_from_pipes(rpipes, rfds, cmd.stderr) - # - # # if we're checking for prompts, do it now - # if prompt_re: - # if prompt_re.search(stdout) and not data: - # if encoding: - # stdout = to_native(stdout, encoding=encoding, errors=errors) - # else: - # stdout = stdout - # return (257, stdout, "A prompt was encountered while running a command, but no input data was specified") - # # only break out if no pipes are left to read or - # # the pipes are completely read and - # # the process is terminated - # if (not rpipes or not rfds) and cmd.poll() is not None: - # break - # # No pipes are left to read but process is not yet terminated - # # Only then it is safe to wait for the process to be finished - # # NOTE: Actually cmd.poll() is always None here if rpipes is empty - # elif not rpipes and cmd.poll() is None: - # cmd.wait() - # # The process is terminated. Since no pipes to read from are - # # left, there is no need to call select() again. - # break - # ZUUL: If the console log follow thread *is* stuck in readline, # we can't close stdout (attempting to do so raises an # exception) , so this is disabled. @@ -467,10 +519,10 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex rc = cmd.wait() - # Give the thread that is writing the console log up to 10 seconds - # to catch up and exit. If it hasn't done so by then, it is very - # likely stuck in readline() because it spawed a child that is - # holding stdout or stderr open. + # ZUUL: Give the thread that is writing the console log up to + # 10 seconds to catch up and exit. If it hasn't done so by + # then, it is very likely stuck in readline() because it + # spawed a child that is holding stdout or stderr open. if t: t.join(10) with Console(zuul_log_id) as console: @@ -485,13 +537,13 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex stderr = b('') except (OSError, IOError) as e: - self.log("Error Executing CMD:%s Exception:%s" % (clean_args, to_native(e))) + self.log("Error Executing CMD:%s Exception:%s" % (self._clean_args(args), to_native(e))) # ZUUL: store fail_json_kwargs and fail later in finally - fail_json_kwargs = dict(rc=e.errno, msg=to_native(e), cmd=clean_args) + fail_json_kwargs = dict(rc=e.errno, stdout=b'', stderr=b'', msg=to_native(e), cmd=self._clean_args(args)) except Exception as e: - self.log("Error Executing CMD:%s Exception:%s" % (clean_args, to_native(traceback.format_exc()))) + self.log("Error Executing CMD:%s Exception:%s" % (self._clean_args(args), to_native(traceback.format_exc()))) # ZUUL: store fail_json_kwargs and fail later in finally - fail_json_kwargs = dict(rc=257, msg=to_native(e), exception=traceback.format_exc(), cmd=clean_args) + fail_json_kwargs = dict(rc=257, stdout=b'', stderr=b'', msg=to_native(e), exception=traceback.format_exc(), cmd=self._clean_args(args)) finally: if t: with Console(zuul_log_id) as console: @@ -508,26 +560,14 @@ def zuul_run_command(self, args, zuul_log_id, check_rc=False, close_fds=True, ex if fail_json_kwargs: self.fail_json(**fail_json_kwargs) - # Restore env settings - for key, val in old_env_vals.items(): - if val is None: - del os.environ[key] - else: - os.environ[key] = val - - if old_umask: - os.umask(old_umask) - if rc != 0 and check_rc: msg = heuristic_log_sanitize(stderr.rstrip(), self.no_log_values) - self.fail_json(cmd=clean_args, rc=rc, stdout=stdout, stderr=stderr, msg=msg) - - # reset the pwd - os.chdir(prev_dir) + self.fail_json(cmd=self._clean_args(args), rc=rc, stdout=stdout, stderr=stderr, msg=msg) if encoding is not None: return (rc, to_native(stdout, encoding=encoding, errors=errors), to_native(stderr, encoding=encoding, errors=errors)) + return (rc, stdout, stderr) @@ -547,19 +587,19 @@ def check_command(module, commandline): command = commandline.split()[0] command = os.path.basename(command) - disable_suffix = "If you need to use command because {mod} is insufficient you can add" \ - " warn=False to this command task or set command_warnings=False in" \ - " ansible.cfg to get rid of this message." + disable_suffix = "If you need to use '{cmd}' because the {mod} module is insufficient you can add" \ + " 'warn: false' to this command task or set 'command_warnings=False' in" \ + " the defaults section of ansible.cfg to get rid of this message." substitutions = {'mod': None, 'cmd': command} if command in arguments: - msg = "Consider using the {mod} module with {subcmd} rather than running {cmd}. " + disable_suffix + msg = "Consider using the {mod} module with {subcmd} rather than running '{cmd}'. " + disable_suffix substitutions['mod'] = 'file' substitutions['subcmd'] = arguments[command] module.warn(msg.format(**substitutions)) if command in commands: - msg = "Consider using the {mod} module rather than running {cmd}. " + disable_suffix + msg = "Consider using the {mod} module rather than running '{cmd}'. " + disable_suffix substitutions['mod'] = commands[command] module.warn(msg.format(**substitutions)) @@ -571,20 +611,24 @@ def main(): # the command module is the one ansible module that does not take key=value args # hence don't copy this one if you are looking to build others! + # NOTE: ensure splitter.py is kept in sync for exceptions module = AnsibleModule( argument_spec=dict( _raw_params=dict(), _uses_shell=dict(type='bool', default=False), - argv=dict(type='list'), + argv=dict(type='list', elements='str'), chdir=dict(type='path'), executable=dict(), creates=dict(type='path'), removes=dict(type='path'), # The default for this really comes from the action plugin - warn=dict(type='bool', default=True), + warn=dict(type='bool', default=False, removed_in_version='2.14', removed_from_collection='ansible.builtin'), stdin=dict(required=False), + stdin_add_newline=dict(type='bool', default=True), + strip_empty_ends=dict(type='bool', default=True), zuul_log_id=dict(type='str'), - ) + ), + supports_check_mode=True, ) shell = module.params['_uses_shell'] chdir = module.params['chdir'] @@ -595,8 +639,13 @@ def main(): removes = module.params['removes'] warn = module.params['warn'] stdin = module.params['stdin'] + stdin_add_newline = module.params['stdin_add_newline'] + strip = module.params['strip_empty_ends'] zuul_log_id = module.params['zuul_log_id'] + # we promissed these in 'always' ( _lines get autoaded on action plugin) + r = {'changed': False, 'stdout': '', 'stderr': '', 'rc': None, 'cmd': None, 'start': None, 'end': None, 'delta': None, 'msg': ''} + if not shell and executable: module.warn("As of Ansible 2.4, the parameter 'executable' is no longer supported with the 'command' module. Not using '%s'." % executable) executable = None @@ -605,70 +654,92 @@ def main(): module.fail_json(rc=256, msg="zuul_log_id missing: %s" % module.params) if (not args or args.strip() == '') and not argv: - module.fail_json(rc=256, msg="no command given") + r['rc'] = 256 + r['msg'] = "no command given" + module.fail_json(**r) if args and argv: - module.fail_json(rc=256, msg="only command or argv can be given, not both") + r['rc'] = 256 + r['msg'] = "only command or argv can be given, not both" + module.fail_json(**r) if not shell and args: args = shlex.split(args) args = args or argv + # All args must be strings + if is_iterable(args, include_strings=False): + args = [to_native(arg, errors='surrogate_or_strict', nonstring='simplerepr') for arg in args] - if chdir: - chdir = os.path.abspath(chdir) - os.chdir(chdir) - - if creates: - # do not run the command if the line contains creates=filename - # and the filename already exists. This allows idempotence - # of command executions. - if glob.glob(creates): - module.exit_json( - cmd=args, - stdout="skipped, since %s exists" % creates, - changed=False, - rc=0 - ) - - if removes: - # do not run the command if the line contains removes=filename - # and the filename does not exist. This allows idempotence - # of command executions. - if not glob.glob(removes): - module.exit_json( - cmd=args, - stdout="skipped, since %s does not exist" % removes, - changed=False, - rc=0 - ) - + r['cmd'] = args if warn: + # nany telling you to use module instead! check_command(module, args) - startd = datetime.datetime.now() + if chdir: + chdir = to_bytes(chdir, errors='surrogate_or_strict') - rc, out, err = zuul_run_command(module, args, zuul_log_id, executable=executable, use_unsafe_shell=shell, encoding=None, data=stdin) + try: + os.chdir(chdir) + except (IOError, OSError) as e: + r['msg'] = 'Unable to change directory before execution: %s' % to_text(e) + module.fail_json(**r) - endd = datetime.datetime.now() - delta = endd - startd + # check_mode partial support, since it only really works in checking creates/removes + if module.check_mode: + shoulda = "Would" + else: + shoulda = "Did" - result = dict( - cmd=args, - stdout=out.rstrip(b"\r\n"), - stderr=err.rstrip(b"\r\n"), - rc=rc, - start=str(startd), - end=str(endd), - delta=str(delta), - changed=True, - zuul_log_id=zuul_log_id - ) + # special skips for idempotence if file exists (assumes command creates) + if creates: + if glob.glob(creates): + r['msg'] = "%s not run command since '%s' exists" % (shoulda, creates) + r['stdout'] = "skipped, since %s exists" % creates # TODO: deprecate - if rc != 0: - module.fail_json(msg='non-zero return code', **result) + r['rc'] = 0 - module.exit_json(**result) + # special skips for idempotence if file does not exist (assumes command removes) + if not r['msg'] and removes: + if not glob.glob(removes): + r['msg'] = "%s not run command since '%s' does not exist" % (shoulda, removes) + r['stdout'] = "skipped, since %s does not exist" % removes # TODO: deprecate + r['rc'] = 0 + + if r['msg']: + module.exit_json(**r) + + # actually executes command (or not ...) + if not module.check_mode: + r['start'] = datetime.datetime.now() + r['rc'], r['stdout'], r['stderr'] = zuul_run_command(module, args, zuul_log_id, executable=executable, use_unsafe_shell=shell, encoding=None, + data=stdin, binary_data=(not stdin_add_newline)) + r['end'] = datetime.datetime.now() + else: + # this is partial check_mode support, since we end up skipping if we get here + r['rc'] = 0 + r['msg'] = "Command would have run if not in check mode" + r['skipped'] = True + + r['changed'] = True + r['zuul_log_id'] = zuul_log_id + + # convert to text for jsonization and usability + if r['start'] is not None and r['end'] is not None: + # these are datetime objects, but need them as strings to pass back + r['delta'] = to_text(r['end'] - r['start']) + r['end'] = to_text(r['end']) + r['start'] = to_text(r['start']) + + if strip: + r['stdout'] = to_text(r['stdout']).rstrip("\r\n") + r['stderr'] = to_text(r['stderr']).rstrip("\r\n") + + if r['rc'] != 0: + r['msg'] = 'non-zero return code' + module.fail_json(**r) + + module.exit_json(**r) if __name__ == '__main__': diff --git a/zuul/configloader.py b/zuul/configloader.py index 425e52f364..ddc8dc4d9d 100644 --- a/zuul/configloader.py +++ b/zuul/configloader.py @@ -607,7 +607,7 @@ class JobParser(object): 'post-run': to_list(str), 'run': to_list(str), 'cleanup-run': to_list(str), - 'ansible-version': vs.Any(str, float), + 'ansible-version': vs.Any(str, float, int), '_source_context': model.SourceContext, '_start_mark': model.ZuulMark, 'roles': to_list(role), @@ -745,8 +745,8 @@ class JobParser(object): # Configure and validate ansible version if 'ansible-version' in conf: - # The ansible-version can be treated by yaml as a float so convert - # it to a string. + # The ansible-version can be treated by yaml as a float or + # int so convert it to a string. ansible_version = str(conf['ansible-version']) self.pcontext.ansible_manager.requestVersion(ansible_version) job.ansible_version = ansible_version diff --git a/zuul/lib/ansible-config.conf b/zuul/lib/ansible-config.conf index 3774516f06..75f32426ac 100644 --- a/zuul/lib/ansible-config.conf +++ b/zuul/lib/ansible-config.conf @@ -12,3 +12,6 @@ requirements = ansible>=2.8,<2.9,!=2.8.16 Jinja2<3.1.0 # Ansible 2.9.14 breaks the k8s connection plugin # https://github.com/ansible/ansible/issues/72171 requirements = ansible>=2.9,<2.10,!=2.9.14 + +[5] +requirements = ansible>=5.0,<6.0