diff --git a/doc/source/operation.rst b/doc/source/operation.rst index 2519fe5e62..e15778fde1 100644 --- a/doc/source/operation.rst +++ b/doc/source/operation.rst @@ -214,14 +214,33 @@ See also the Ansible `Python 3 support page .. _nodepool_console_streaming: -Log streaming +Log Streaming ~~~~~~~~~~~~~ The log streaming service enables Zuul to show the live status of -long-running ``shell`` or ``command`` tasks. The server side is setup -by the ``zuul_console:`` task built-in to Zuul's Ansible installation. -The executor requires the ability to communicate with this server on -the job nodes via port ``19885`` for this to work. +long-running ``shell``, ``command``, ``win_shell``, or ``win_command`` +tasks. + +Log streaming is available on both Posix and Windows based hosts. The +two systems operate in the same way with some minor differences. They +are compatible, and in the case where a Windows host runs Windows +Subsystem for Linux (WSL), they may operate at the same time. + +For Kubernetes-based job nodes the connection from the executor to the +log streaming daemon is established by using ``kubectl port-forward`` +to forward a local port to the appropriate port on the pod containing +the job node. If the Kubernetes user is not bound to a role that has +authorization for port-forwarding, this will prevent connection to +the daemon. + +Posix Log Streaming ++++++++++++++++++++ + +The Posix log streaming service handles output from ``shell`` and +``command`` tasks. The server side is setup by the ``zuul_console:`` +task built-in to Zuul's Ansible installation. The executor requires +the ability to communicate with this server on the job nodes via port +``19885`` for this to work. The log streaming service spools command output via files on the job node in the format ``/tmp/console---.log``. By @@ -241,19 +260,43 @@ some other reason, the command to clean these spool files will not be processed and they may be left behind; on an ephemeral node this is not usually a problem, but on a static node these files will persist. -In this situation, Zuul can be instructed to not to create any spool -files for ``shell`` and ``command`` tasks via setting -``zuul_console_disabled: True`` (usually via a global host variable in -inventory). Live streaming of ``shell`` and ``command`` calls will of +In this situation, Zuul can be instructed not to create any spool +files for ``shell``, ``command``, ``win_shell``, or ``win_command`` +tasks by setting ``zuul_console_disabled: True`` (usually via a global +host variable in inventory). Live streaming of these tasks will of +course be unavailable in this case, but no spool files will be +created. + +Windows Log Streaming ++++++++++++++++++++++ + +The Windows log streaming service handles output from ``win_shell`` +and ``win_command`` tasks. The server side is setup by the +``win_zuul_console:`` task built-in to Zuul's Ansible installation. +The executor requires the ability to communicate with this server on +the job nodes via port ``19886`` for this to work. + +The log streaming service spools command output via files on the job +node in the format ``C:/Users/All +Users/Zuul/console-console---.log``. By default, +it will clean these files up automatically. + +Occasionally, a streaming file may be left if a job is interrupted. +These may be safely removed after a short period of inactivity. + +If the executor is unable to reach port ``19886`` (for example due to +firewall rules), or the ``win_zuul_console`` daemon can not be run for +some other reason, the command to clean these spool files will not be +processed and they may be left behind; on an ephemeral node this is +not usually a problem, but on a static node these files will persist. + +In this situation, Zuul can be instructed not to create any spool +files for ``shell``, ``command``, ``win_shell``, or ``win_command`` +tasks by setting ``zuul_console_disabled: True`` (usually via a global +host variable in inventory). Live streaming of these tasks will of course be unavailable in this case, but no spool files will be created. -For Kubernetes-based job nodes the connection from the executor to the -``zuul_console`` daemon is established by using ``kubectl port-forward`` -to forward a local port to the appropriate port on the pod containing -the job node. If the Kubernetes user is not bound to a role that has -authorization for port-forwarding, this will prevent connection to -the ``zuul_console`` daemon. Web Server ---------- diff --git a/releasenotes/notes/windows-streaming-d5a2dae41b4c826d.yaml b/releasenotes/notes/windows-streaming-d5a2dae41b4c826d.yaml new file mode 100644 index 0000000000..be2036c443 --- /dev/null +++ b/releasenotes/notes/windows-streaming-d5a2dae41b4c826d.yaml @@ -0,0 +1,24 @@ +--- +features: + - | + Live console log streaming is now available for ``win_shell` and + ``win_command`` tasks on Windows hosts. +upgrade: + - | + Due to the addition of live console log streaming for Windows + hosts, one of the following is necessary: + + * Add the ``zuul_win_console:`` task to the first pre-run + playbook in a base job for Windows hosts, and allow + connections to port 19886 on the hosts. This will allow for + automatic live log streaming in the same manner as under Posix + systems. + + * If the above is not practical on long-lived Windows hosts, set + ``zuul_console_disabled: True`` for those hosts. This will + disable writing console output to spool files on the remote + hosts which would otherwise not be deleted. + + Note that the Posix and Windows log streaming servers operate on + different ports (19885 and 19886 respectively) in order to allow + both to coexist on the same host. diff --git a/tests/base.py b/tests/base.py index a90a5e3d41..596d398d1f 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1334,6 +1334,8 @@ class FakeNodepool(object): data['resources'] = self.resources if self.remote_ansible: data['connection_type'] = 'ssh' + if os.environ.get("ZUUL_REMOTE_USER"): + data['username'] = os.environ.get("ZUUL_REMOTE_USER") if 'fakeuser' in node_type: data['username'] = 'fakeuser' if 'windows' in node_type: diff --git a/tests/fixtures/config/remote-zuul-stream/git/org_project/playbooks/win-command.yaml b/tests/fixtures/config/remote-zuul-stream/git/org_project/playbooks/win-command.yaml new file mode 100644 index 0000000000..735a3becc0 --- /dev/null +++ b/tests/fixtures/config/remote-zuul-stream/git/org_project/playbooks/win-command.yaml @@ -0,0 +1,183 @@ +- hosts: localhost + tasks: + - debug: + msg: Ansible version={{ ansible_version.major }}.{{ ansible_version.minor }} + +- hosts: all + tasks: + # This is a noop to make the task numbers line up with the console job. + - name: "Noop" + debug: + msg: noop + run_once: True + + - name: Start zuul_console daemon + win_zuul_console: + port: "{{ test_console_port }}" + + # This is checked in indexed JSON output so order is important + - name: Output stream test + win_shell: | + echo "Standard output test {{ zuul.executor.src_root }}" + [Console]::Error.WriteLine("Standard error test {{ zuul.executor.src_root }}") + + - name: Create first file + win_copy: + content: "command test one\n" + dest: "{{ ansible_user_dir }}/command_test_file1" + - name: Create second file + win_copy: + content: "command test two\n" + dest: "{{ ansible_user_dir }}/command_test_file2" + - name: Show contents of first file + win_command: "cmd /c type {{ ansible_user_dir }}\\command_test_file1" + - name: Show contents of second file + # We use a sleep here to ensure that we log even after + # a period of no logging. + win_shell: "sleep 6; cat {{ ansible_user_dir }}/command_test_file2" + + # Test a task with a handler + - name: Run a command with notifying a handler + win_command: "cmd /c exit 0" + notify: test handler + + # Test cleanup task + - name: Block with cleanup + block: + - name: Run a command + win_command: "cmd /c exit 1" + rescue: + - name: Rescue task + win_command: 'cmd /c echo This is a rescue task' + always: + - name: Always task + win_command: 'cmd /c echo This is an always task' + + - name: Skip command task + win_command: "cmd /c exit 0" + when: false + + - name: Skip command task loop + win_command: "cmd /c exit 0" + when: false + with_items: + - failed_in_loop1 + - failed_in_loop2 + + - name: Set testfile path name for later test + set_fact: + testfile: "{{ ansible_user_dir }}\\testfile-{{ zuul.build }}" + + handlers: + - name: test handler + win_command: 'cmd /c echo This is a handler' + +- hosts: all + strategy: free + tasks: + - name: Command task 1 within free strategy + win_command: 'cmd /c echo First free task' + - name: Command task 2 within free strategy + win_command: 'cmd /c echo Second free task' + +# Test a role that has an include_role +- hosts: all + strategy: linear + roles: + - win-include-a-role + +- hosts: compute1 + tasks: + - name: Single command + win_command: 'cmd /c echo single' + # Test commands within loops + - name: Command with loop + win_shell: | + echo {{ item }} + with_items: + - item_in_loop1 + - item_in_loop2 + - name: Failing command with loop + win_shell: | + echo {{ item }} + exit 1 + with_items: + - failed_in_loop1 + - failed_in_loop2 + ignore_errors: True + - name: Creates file that does not exist + win_command: 'cmd /c "echo foo > {{ testfile }}"' + args: + creates: "{{ testfile }}" + - name: Creates file that already exists + win_command: 'cmd /c "echo foo > {{ testfile }}"' + args: + creates: "{{ testfile }}" + +# Try transitive includes two different ways +- hosts: compute1 + tasks: + - include_role: + name: win-include-echo-role + vars: + item: transitive-one + - include_role: + name: win-include-echo-role + vars: + item: transitive-two + +- hosts: compute1 + roles: + - role: win-include-echo-role + item: transitive-three + - role: win-include-echo-role + item: transitive-four + +- hosts: compute1 + tasks: + - name: Command Not Found + win_command: command-not-found + failed_when: false + +- hosts: compute1 + tasks: + + - name: Debug raw variable in msg + debug: + msg: '{{ ansible_version }}' + + - name: Debug raw variable in a loop + debug: + msg: '{{ ansible_version }}' + loop: + - 1 + - 2 + +- hosts: all + tasks: + - name: Clean up tmpfile + win_file: + path: "{{ testfile }}" + state: absent + # win_file may not be idempotent + failed_when: false + # Test large output + - name: Output 70KB each to stdout and stderr + # We use print (as opposed to write) so this uses buffered + # output in order to encourage a deadlock. + win_shell: python -c "import sys; print('x' * 70000, file=sys.stdout); print('x' * 70000, file=sys.stderr)" + +- hosts: localhost + gather_facts: no + tasks: + - name: Add a fake host + add_host: + hostname: fake + ansible_host: notexisting.example.notexisting + +- hosts: fake + gather_facts: no + tasks: + - name: Skip a command on an unreachable host + win_command: cmd /c echo nope + when: false diff --git a/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-echo-role/tasks/main.yaml b/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-echo-role/tasks/main.yaml new file mode 100644 index 0000000000..dae0e63537 --- /dev/null +++ b/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-echo-role/tasks/main.yaml @@ -0,0 +1,2 @@ +- name: Echo message + win_command: "cmd /c echo {{item}}" diff --git a/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-include-a-role/tasks/main.yaml b/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-include-a-role/tasks/main.yaml new file mode 100644 index 0000000000..378b7d3730 --- /dev/null +++ b/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-include-a-role/tasks/main.yaml @@ -0,0 +1,14 @@ +# We've seen callback problems with shell tasks after included roles. +- name: Emit a debug message + include_role: + name: debug-role + +- name: Include role shell task + win_shell: echo "This is a shell task after an included role" + +- name: Include role command task + win_command: cmd /c echo This is a command task after an included role + +- name: Include role shell task delegate + win_shell: echo "This is a shell task with delegate {{ inventory_hostname }}" + delegate_to: controller diff --git a/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-include-echo-role/tasks/main.yaml b/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-include-echo-role/tasks/main.yaml new file mode 100644 index 0000000000..d8521cb148 --- /dev/null +++ b/tests/fixtures/config/remote-zuul-stream/git/org_project/roles/win-include-echo-role/tasks/main.yaml @@ -0,0 +1,3 @@ +- name: Include echo role + include_role: + name: win-echo-role diff --git a/tests/fixtures/fake_kubectl.sh b/tests/fixtures/fake_kubectl.sh index 86e26e5241..a1425f253d 100755 --- a/tests/fixtures/fake_kubectl.sh +++ b/tests/fixtures/fake_kubectl.sh @@ -4,12 +4,15 @@ # --kubeconfig=/tmp/tmppm0yyqvv/zuul-test/builds/c21fc1eb7e2c469cb4997d688252dc3c/work/.kube/config --context=zuul-ci-abcdefg:zuul-worker/ -n zuul-ci-abcdefg port-forward pod/fedora-abcdefg 37303:19885 # Get the last argument to the script -arg=${@:$#} +arg1=${@: -2} +arg2=${@: -1} # Split on the colon -ports=(${arg//:/ }) +ports1=(${arg1//:/ }) +ports2=(${arg2//:/ }) -echo "Forwarding from 127.0.0.1:${ports[0]} -> ${ports[1]}" +echo "Forwarding from 127.0.0.1:${ports1[0]} -> ${ports1[1]}" +echo "Forwarding from 127.0.0.1:${ports2[0]} -> ${ports2[1]}" while true; do sleep 5 diff --git a/tests/remote/test_remote_zuul_stream.py b/tests/remote/test_remote_zuul_stream.py index 79774f89cc..03dab698b4 100644 --- a/tests/remote/test_remote_zuul_stream.py +++ b/tests/remote/test_remote_zuul_stream.py @@ -462,6 +462,139 @@ class FunctionalZuulStreamMixIn: with open(path) as f: self.log.debug(f.read()) + @skip("Windows unavailable in gate") + def test_win_command(self): + self.fake_nodepool.shell_type = 'cmd' + job = self._run_job('win-command') + with self.jobLog(job): + build = self.history[-1] + self.assertEqual(build.result, 'SUCCESS') + + console_output = self.console_output.getvalue() + # This should be generic enough to match any callback + # plugin failures, which look something like + # + # [WARNING]: Failure using method (v2_runner_on_ok) in \ + # callback plugin + # (): 'dict' object has no attribute 'startswith'" + # Callback Exception: + # ... + # + self.assertNotIn('[WARNING]: Failure using method', console_output) + + text = self._get_job_output(build) + data = self._get_job_json(build) + + # The win_ modules do not have a strip_trailing_whitespace + # option like the unix ones (and the unix ones default to + # true), so we get a CRLF at the end of our stdout stream. + token_stdout = "Standard output test {}\r\n".format( + self.history[0].jobdir.src_root) + # The win_shell module trims the stderr string as part of + # its CLIXML handling, so there is no CRLF here. + token_stderr = "Standard error test {}".format( + self.history[0].jobdir.src_root) + result = data[0]['plays'][1]['tasks'][2]['hosts']['compute1'] + self.assertEqual(token_stdout, result['stdout']) + self.assertEqual(token_stderr, result['stderr']) + + # Find the "creates" tasks + create1_task = data[0]['plays'][4]['tasks'][3] + create1_host = create1_task['hosts']['compute1'] + self.assertIsNotNone(create1_host['delta']) + self.assertNotIn("skipped, since", create1_host.get('msg', '')) + self.assertEqual("Creates file that does not exist", + create1_task['task']['name']) + create2_task = data[0]['plays'][4]['tasks'][4] + create2_host = create2_task['hosts']['compute1'] + self.assertIsNone(create2_host.get('delta')) + self.assertIn("skipped, since", create2_host['msg']) + self.assertEqual("Creates file that already exists", + create2_task['task']['name']) + # There is no "delta" returned in this case, so we don't + # get a result linee + # self.assertLogLine(r'compute1 \| ok: Runtime: None', text) + + self.assertLogLine( + r'RUN START: \[untrusted : review.example.com/org/project/' + r'playbooks/win-command.yaml@master\]', text) + self.assertLogLine(r'PLAY \[all\]', text) + self.assertLogLine( + 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( + r'controller \| ok: Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) + self.assertLogLine(r'TASK \[Show contents of second file\]', text) + self.assertLogLine(r'compute1 \| command test two', text) + self.assertLogLine(r'controller \| command test two', text) + self.assertLogLine(r'compute1 \| This is a rescue task', text) + self.assertLogLine(r'controller \| This is a rescue task', text) + self.assertLogLine(r'compute1 \| This is an always task', text) + self.assertLogLine(r'controller \| This is an always task', text) + self.assertLogLine(r'compute1 \| This is a handler', text) + self.assertLogLine(r'controller \| This is a handler', text) + self.assertLogLine(r'controller \| First free task', text) + self.assertLogLine(r'controller \| Second free task', text) + self.assertLogLine(r'controller \| This is a shell task after an ' + 'included role', text) + self.assertLogLine(r'compute1 \| This is a shell task after an ' + 'included role', text) + self.assertLogLine(r'controller \| This is a command task after ' + 'an included role', text) + self.assertLogLine(r'compute1 \| This is a command task after an ' + 'included role', text) + self.assertLogLine(r'controller \| This is a shell task with ' + 'delegate compute1', text) + self.assertLogLine(r'controller \| This is a shell task with ' + 'delegate controller', text) + self.assertLogLine(r'compute1 \| item_in_loop1', text) + self.assertLogLine(r'compute1 \| ok: Item: item_in_loop1 ' + r'Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) + self.assertLogLine(r'compute1 \| item_in_loop2', text) + self.assertLogLine(r'compute1 \| ok: Item: item_in_loop2 ' + r'Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) + self.assertLogLine(r'compute1 \| failed_in_loop1', text) + self.assertLogLine(r'compute1 \| ok: Item: failed_in_loop1 ' + r'Result: 1', text) + 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 \| transitive-one', text) + self.assertLogLine(r'compute1 \| transitive-two', text) + self.assertLogLine(r'compute1 \| transitive-three', text) + self.assertLogLine(r'compute1 \| transitive-four', text) + self.assertLogLine( + r'controller \| ok: Runtime: \d:\d\d:\d\d\.\d\d\d\d\d\d', text) + self.assertLogLine('PLAY RECAP', text) + self.assertLogLine( + r'controller \| ok: \d+ changed: \d+ unreachable: 0 failed: 0 ' + 'skipped: 2 rescued: 1 ignored: 0', text) + self.assertLogLine( + r'RUN END RESULT_NORMAL: \[untrusted : review.example.com/' + r'org/project/playbooks/win-command.yaml@master]', text) + time1, time2 = self._getLogTime(r'TASK \[Command Not Found\]', + text) + self.assertLess((time2 - time1) / timedelta(milliseconds=1), + 9000) + + # This is from the debug: msg='{{ ansible_version }}' + # testing raw variable output. To make it version + # agnostic, match just the start of + # compute1 | ok: {'string': '2.9.27'... + + # NOTE(ianw) 2022-08-24 : I don't know why the callback + # for debug: msg= doesn't put the hostname first like + # other output. Undetermined if bug or feature. + self.assertLogLineStartsWith( + r"""\{'string': '\d.""", text) + # ... handling loops is a different path, and that does + self.assertLogLineStartsWith( + r"""compute1 \| ok: \{'string': '\d.""", text) + self.assertLogLine( + r'fake \| skipping: Conditional result was False', text) + class TestZuulStream8(AnsibleZuulTestCase, FunctionalZuulStreamMixIn): ansible_version = '8' diff --git a/zuul/ansible/8/action/win_command.py b/zuul/ansible/8/action/win_command.py new file mode 120000 index 0000000000..2b1909fe14 --- /dev/null +++ b/zuul/ansible/8/action/win_command.py @@ -0,0 +1 @@ +../../base/action/win_command.py \ No newline at end of file diff --git a/zuul/ansible/8/action/win_shell.py b/zuul/ansible/8/action/win_shell.py new file mode 120000 index 0000000000..fe8ef0879d --- /dev/null +++ b/zuul/ansible/8/action/win_shell.py @@ -0,0 +1 @@ +../../base/action/win_shell.py \ No newline at end of file diff --git a/zuul/ansible/8/action/win_zuul_console.py b/zuul/ansible/8/action/win_zuul_console.py new file mode 120000 index 0000000000..42016458e4 --- /dev/null +++ b/zuul/ansible/8/action/win_zuul_console.py @@ -0,0 +1 @@ +../../base/action/win_zuul_console.py \ No newline at end of file diff --git a/zuul/ansible/8/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 b/zuul/ansible/8/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 new file mode 120000 index 0000000000..a727d45490 --- /dev/null +++ b/zuul/ansible/8/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 \ No newline at end of file diff --git a/zuul/ansible/8/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 b/zuul/ansible/8/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 new file mode 120000 index 0000000000..2f38ce84da --- /dev/null +++ b/zuul/ansible/8/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 \ No newline at end of file diff --git a/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Command.Process.cs b/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Command.Process.cs new file mode 120000 index 0000000000..df87a7b9d8 --- /dev/null +++ b/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Command.Process.cs @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.Zuul.Win.Command.Process.cs \ No newline at end of file diff --git a/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Common.cs b/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Common.cs new file mode 120000 index 0000000000..b918a43f16 --- /dev/null +++ b/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Common.cs @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.Zuul.Win.Common.cs \ No newline at end of file diff --git a/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs b/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs new file mode 120000 index 0000000000..00661b5a7b --- /dev/null +++ b/zuul/ansible/8/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs \ No newline at end of file diff --git a/zuul/ansible/8/library/module_utils/__init__.py b/zuul/ansible/8/library/module_utils/__init__.py new file mode 120000 index 0000000000..a50b90c33e --- /dev/null +++ b/zuul/ansible/8/library/module_utils/__init__.py @@ -0,0 +1 @@ +../../../base/library/module_utils/__init__.py \ No newline at end of file diff --git a/zuul/ansible/8/library/win_command.ps1 b/zuul/ansible/8/library/win_command.ps1 new file mode 120000 index 0000000000..b6d219d5f1 --- /dev/null +++ b/zuul/ansible/8/library/win_command.ps1 @@ -0,0 +1 @@ +../../base/library/win_command.ps1 \ No newline at end of file diff --git a/zuul/ansible/8/library/win_shell.ps1 b/zuul/ansible/8/library/win_shell.ps1 new file mode 120000 index 0000000000..9228ab4d6e --- /dev/null +++ b/zuul/ansible/8/library/win_shell.ps1 @@ -0,0 +1 @@ +../../base/library/win_shell.ps1 \ No newline at end of file diff --git a/zuul/ansible/8/library/win_zuul_console.ps1 b/zuul/ansible/8/library/win_zuul_console.ps1 new file mode 120000 index 0000000000..ca83e98f18 --- /dev/null +++ b/zuul/ansible/8/library/win_zuul_console.ps1 @@ -0,0 +1 @@ +../../base/library/win_zuul_console.ps1 \ No newline at end of file diff --git a/zuul/ansible/8/win_zuul_console.cs b/zuul/ansible/8/win_zuul_console.cs new file mode 120000 index 0000000000..060a90791b --- /dev/null +++ b/zuul/ansible/8/win_zuul_console.cs @@ -0,0 +1 @@ +../win_zuul_console.cs \ No newline at end of file diff --git a/zuul/ansible/9/action/win_command.py b/zuul/ansible/9/action/win_command.py new file mode 120000 index 0000000000..2b1909fe14 --- /dev/null +++ b/zuul/ansible/9/action/win_command.py @@ -0,0 +1 @@ +../../base/action/win_command.py \ No newline at end of file diff --git a/zuul/ansible/9/action/win_shell.py b/zuul/ansible/9/action/win_shell.py new file mode 120000 index 0000000000..fe8ef0879d --- /dev/null +++ b/zuul/ansible/9/action/win_shell.py @@ -0,0 +1 @@ +../../base/action/win_shell.py \ No newline at end of file diff --git a/zuul/ansible/9/action/win_zuul_console.py b/zuul/ansible/9/action/win_zuul_console.py new file mode 120000 index 0000000000..42016458e4 --- /dev/null +++ b/zuul/ansible/9/action/win_zuul_console.py @@ -0,0 +1 @@ +../../base/action/win_zuul_console.py \ No newline at end of file diff --git a/zuul/ansible/9/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 b/zuul/ansible/9/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 new file mode 120000 index 0000000000..a727d45490 --- /dev/null +++ b/zuul/ansible/9/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 \ No newline at end of file diff --git a/zuul/ansible/9/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 b/zuul/ansible/9/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 new file mode 120000 index 0000000000..2f38ce84da --- /dev/null +++ b/zuul/ansible/9/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 \ No newline at end of file diff --git a/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Command.Process.cs b/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Command.Process.cs new file mode 120000 index 0000000000..df87a7b9d8 --- /dev/null +++ b/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Command.Process.cs @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.Zuul.Win.Command.Process.cs \ No newline at end of file diff --git a/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Common.cs b/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Common.cs new file mode 120000 index 0000000000..b918a43f16 --- /dev/null +++ b/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Common.cs @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.Zuul.Win.Common.cs \ No newline at end of file diff --git a/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs b/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs new file mode 120000 index 0000000000..00661b5a7b --- /dev/null +++ b/zuul/ansible/9/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs @@ -0,0 +1 @@ +../../../base/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs \ No newline at end of file diff --git a/zuul/ansible/9/library/module_utils/__init__.py b/zuul/ansible/9/library/module_utils/__init__.py new file mode 120000 index 0000000000..a50b90c33e --- /dev/null +++ b/zuul/ansible/9/library/module_utils/__init__.py @@ -0,0 +1 @@ +../../../base/library/module_utils/__init__.py \ No newline at end of file diff --git a/zuul/ansible/9/library/win_command.ps1 b/zuul/ansible/9/library/win_command.ps1 new file mode 120000 index 0000000000..b6d219d5f1 --- /dev/null +++ b/zuul/ansible/9/library/win_command.ps1 @@ -0,0 +1 @@ +../../base/library/win_command.ps1 \ No newline at end of file diff --git a/zuul/ansible/9/library/win_shell.ps1 b/zuul/ansible/9/library/win_shell.ps1 new file mode 120000 index 0000000000..9228ab4d6e --- /dev/null +++ b/zuul/ansible/9/library/win_shell.ps1 @@ -0,0 +1 @@ +../../base/library/win_shell.ps1 \ No newline at end of file diff --git a/zuul/ansible/9/library/win_zuul_console.ps1 b/zuul/ansible/9/library/win_zuul_console.ps1 new file mode 120000 index 0000000000..ca83e98f18 --- /dev/null +++ b/zuul/ansible/9/library/win_zuul_console.ps1 @@ -0,0 +1 @@ +../../base/library/win_zuul_console.ps1 \ No newline at end of file diff --git a/zuul/ansible/9/win_zuul_console.cs b/zuul/ansible/9/win_zuul_console.cs new file mode 120000 index 0000000000..060a90791b --- /dev/null +++ b/zuul/ansible/9/win_zuul_console.cs @@ -0,0 +1 @@ +../win_zuul_console.cs \ No newline at end of file diff --git a/zuul/ansible/base/action/win_command.py b/zuul/ansible/base/action/win_command.py new file mode 100644 index 0000000000..eb183b3cf9 --- /dev/null +++ b/zuul/ansible/base/action/win_command.py @@ -0,0 +1,55 @@ +# Copyright 2018 BMW Car IT GmbH +# Copyright 2025 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 . + +import os + +from zuul.ansible import paths + +from ansible.module_utils.parsing.convert_bool import boolean + +normal = paths._import_ansible_action_plugin("normal") + + +class ActionModule(normal.ActionModule): + + def run(self, tmp=None, task_vars=None): + # Overloading the UUID is a bit lame, but it stops us + # having to modify the library command.py too much. Both + # of these below stop the creation of the files on disk + # for situations where they won't be read and cleaned-up. + skip = boolean( + self._templar.template( + "{{zuul_console_disabled|default(false)}}")) + if skip: + self._task.args['zuul_log_id'] = 'skip' + elif 'ansible_loop_var' in task_vars: + # we do not log loops in the zuul_stream.py callback. + self._task.args['zuul_log_id'] = 'in-loop-ignore' + else: + # Get a unique key for ZUUL_LOG_ID_MAP. ZUUL_LOG_ID_MAP + # is read-only since we are forked. Use it to add a + # counter to the log id so that if we run the same task + # more than once, we get a unique log file. See comments + # in paths.py for details. + log_host = paths._sanitize_filename( + task_vars.get('inventory_hostname')) + key = "%s-%s" % (self._task._uuid, log_host) + count = paths.ZUUL_LOG_ID_MAP.get(key, 0) + self._task.args['zuul_log_id'] = "%s-%s-%s" % ( + self._task._uuid, count, log_host) + self._task.args["zuul_output_max_bytes"] = int( + os.environ["ZUUL_OUTPUT_MAX_BYTES"]) + return super(ActionModule, self).run(tmp, task_vars) diff --git a/zuul/ansible/base/action/win_shell.py b/zuul/ansible/base/action/win_shell.py new file mode 100644 index 0000000000..eb183b3cf9 --- /dev/null +++ b/zuul/ansible/base/action/win_shell.py @@ -0,0 +1,55 @@ +# Copyright 2018 BMW Car IT GmbH +# Copyright 2025 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 . + +import os + +from zuul.ansible import paths + +from ansible.module_utils.parsing.convert_bool import boolean + +normal = paths._import_ansible_action_plugin("normal") + + +class ActionModule(normal.ActionModule): + + def run(self, tmp=None, task_vars=None): + # Overloading the UUID is a bit lame, but it stops us + # having to modify the library command.py too much. Both + # of these below stop the creation of the files on disk + # for situations where they won't be read and cleaned-up. + skip = boolean( + self._templar.template( + "{{zuul_console_disabled|default(false)}}")) + if skip: + self._task.args['zuul_log_id'] = 'skip' + elif 'ansible_loop_var' in task_vars: + # we do not log loops in the zuul_stream.py callback. + self._task.args['zuul_log_id'] = 'in-loop-ignore' + else: + # Get a unique key for ZUUL_LOG_ID_MAP. ZUUL_LOG_ID_MAP + # is read-only since we are forked. Use it to add a + # counter to the log id so that if we run the same task + # more than once, we get a unique log file. See comments + # in paths.py for details. + log_host = paths._sanitize_filename( + task_vars.get('inventory_hostname')) + key = "%s-%s" % (self._task._uuid, log_host) + count = paths.ZUUL_LOG_ID_MAP.get(key, 0) + self._task.args['zuul_log_id'] = "%s-%s-%s" % ( + self._task._uuid, count, log_host) + self._task.args["zuul_output_max_bytes"] = int( + os.environ["ZUUL_OUTPUT_MAX_BYTES"]) + return super(ActionModule, self).run(tmp, task_vars) diff --git a/zuul/ansible/base/action/win_zuul_console.py b/zuul/ansible/base/action/win_zuul_console.py new file mode 100644 index 0000000000..3700b8fe0a --- /dev/null +++ b/zuul/ansible/base/action/win_zuul_console.py @@ -0,0 +1,53 @@ +# Copyright 2025 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 . + +import importlib.resources + +from ansible.plugins.action import ActionBase +from ansible.utils.vars import merge_hash + +START_SCRIPT = """ +(Add-Type -Path "{remote_cs_path}") +[WinZuulConsole]::Main($args) +""" + + +class ActionModule(ActionBase): + TRANSFERS_FILES = True + + def run(self, tmp=None, task_vars=None): + results = super(ActionModule, self).run(tmp, task_vars) + + # Copy our csharp server code over + local_path = str(importlib.resources.files('zuul').joinpath( + 'ansible/win_zuul_console.cs')) + remote_cs_path = self._connection._shell.join_path( + self._connection._shell.tmpdir, + 'win_zuul_console.cs') + self._transfer_file(local_path, remote_cs_path) + + # Copy the bootstrap script + remote_ps_path = self._connection._shell.join_path( + self._connection._shell.tmpdir, + 'win_zuul_console_start.ps1') + self._transfer_data( + remote_ps_path, + START_SCRIPT.format(remote_cs_path=remote_cs_path)) + self._task.args['_zuul_console_exec_path'] = remote_ps_path + + # Run the module + results = merge_hash(results, self._execute_module( + module_name='win_zuul_console', task_vars=task_vars)) + return results diff --git a/zuul/ansible/base/callback/zuul_stream.py b/zuul/ansible/base/callback/zuul_stream.py index e67a1cb764..6f9484824b 100644 --- a/zuul/ansible/base/callback/zuul_stream.py +++ b/zuul/ansible/base/callback/zuul_stream.py @@ -1,5 +1,5 @@ # Copyright 2017 Red Hat, Inc. -# Copyright 2024 Acme Gating, LLC +# Copyright 2024-2025 Acme Gating, LLC # # Zuul is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -54,10 +54,37 @@ from zuul.ansible import logconfig LOG_STREAM_VERSION = 0 -# This is intended to be only used for testing where we change the -# port so we can run another instance that doesn't conflict with one -# setup by the test environment -LOG_STREAM_PORT = int(os.environ.get("ZUUL_CONSOLE_PORT", 19885)) +DEFAULT_POSIX_PORT = 19885 +DEFAULT_WINDOWS_PORT = 19886 + +POSIX_ACTIONS = ( + 'command', + 'shell', + 'ansible.builtin.command', + 'ansible.builtin.shell', +) + +WINDOWS_ACTIONS = ( + 'win_command', + 'win_shell', + 'ansible.windows.win_command', + 'ansible.windows.win_shell', +) + +ACTION_LOG_STREAM_PORT = { + **{a: DEFAULT_POSIX_PORT for a in POSIX_ACTIONS}, + **{a: DEFAULT_WINDOWS_PORT for a in WINDOWS_ACTIONS}, +} + +# Actions that support live log streaming +STREAMING_ACTIONS = POSIX_ACTIONS + WINDOWS_ACTIONS + +# Actions that produce stdout/err that should go in the log from JSON +OUTPUT_ACTIONS = ('raw',) + +# Actions that produce stdout/err that should go in the log (whether +# from streaming or JSON). +ALL_ACTIONS = STREAMING_ACTIONS + OUTPUT_ACTIONS def zuul_filter_result(result): @@ -437,13 +464,20 @@ class CallbackModule(default.CallbackModule): if task.async_val: # Don't try to stream from async tasks return - if task.action in ('command', 'shell', - 'ansible.builtin.command', 'ansible.builtin.shell'): + if task.action in STREAMING_ACTIONS: play_vars = self._play._variable_manager._hostvars hosts = self._get_task_hosts(task) for host, inventory_hostname in hosts: - port = LOG_STREAM_PORT + default_port = ACTION_LOG_STREAM_PORT.get(task.action) + # This is intended to be only used for testing where + # we change the port so we can run another instance + # that doesn't conflict with one setup by the test + # environment + port = int(os.environ.get("ZUUL_CONSOLE_PORT", default_port)) + if port is None: + continue + if (host in ('localhost', '127.0.0.1')): # Don't try to stream from localhost continue @@ -462,8 +496,12 @@ class CallbackModule(default.CallbackModule): continue if play_vars[host].get('ansible_connection') in ('kubectl', ): # Stream from the forwarded port on kubectl conns + if task.action in WINDOWS_ACTIONS: + port_id = 'stream_port2' + else: + port_id = 'stream_port1' port = play_vars[host]['zuul']['resources'][ - inventory_hostname].get('stream_port') + inventory_hostname].get(port_id) if port is None: self._log("[Zuul] Kubectl and socat must be installed " "on the Zuul executor for streaming output " @@ -554,19 +592,11 @@ class CallbackModule(default.CallbackModule): if not is_localhost and is_task: self._stop_streamers() - if result._task.action in ('raw', 'command', 'shell', - 'win_command', 'win_shell', - 'ansible.builtin.raw', - 'ansible.windows.win_command', - 'ansible.windows.win_shell'): + if result._task.action in ALL_ACTIONS: stdout_lines = zuul_filter_result(result_dict) - # We don't have streaming for localhost and windows modules so get - # standard out after the fact. - if is_localhost or result._task.action in ( - 'raw', 'win_command', 'win_shell', - 'ansible.builtin.raw', - 'ansible.windows.win_command', - 'ansible.windows.win_shell'): + # We don't have streaming for localhost so get standard + # out after the fact. + if is_localhost or result._task.action in OUTPUT_ACTIONS: for line in stdout_lines: hostname = self._get_hostname(result) self._log("%s | %s " % (hostname, line)) @@ -645,6 +675,17 @@ class CallbackModule(default.CallbackModule): if 'not run command since' in result_dict.get('msg', ''): self._stop_skipped_task_streamer(result._task) + if result._task.action in ('win_command', 'win_shell'): + # The win_command module has a small set of msgs it returns; + # we can use that to detect if decided not to execute the + # command: + # "skipped, since $creates exists" and "skipped, since + # $removes does not exist" are the messages we're looking + # for. + m = result_dict.get('msg', '') + if 'skipped, since' in m and 'exist' in m: + self._stop_skipped_task_streamer(result._task) + if (self._play.strategy == 'free' and self._last_task_banner != result._task._uuid): self._print_task_banner(result._task) @@ -705,7 +746,7 @@ class CallbackModule(default.CallbackModule): self._log_message( msg=json.dumps(result_dict, indent=2, sort_keys=True), status=status, result=result) - elif result._task.action not in ('command', 'shell'): + elif result._task.action not in STREAMING_ACTIONS: if 'msg' in result_dict: self._log_message(msg=result_dict['msg'], result=result, status=status) @@ -750,11 +791,7 @@ class CallbackModule(default.CallbackModule): if to_text(result_dict.get('msg', '')).startswith('MODULE FAILURE'): self._log_module_failure(result, result_dict) - elif result._task.action not in ('raw', 'command', 'shell', - 'win_command', 'win_shell', - 'ansible.builtin.raw', - 'ansible.windows.win_command', - 'ansible.windows.win_shell'): + elif result._task.action not in ALL_ACTIONS: if 'msg' in result_dict: self._log_message( result=result, msg=result_dict['msg'], status=status) @@ -796,11 +833,7 @@ class CallbackModule(default.CallbackModule): if to_text(result_dict.get('msg', '')).startswith('MODULE FAILURE'): self._log_module_failure(result, result_dict) - elif result._task.action not in ('raw', 'command', 'shell', - 'win_command', 'win_shell', - 'ansible.builtin.raw', - 'ansible.windows.win_command', - 'ansible.windows.win_shell'): + elif result._task.action not in ALL_ACTIONS: self._log_message( result=result, msg="Item: {loop_var}".format(loop_var=result_dict[loop_var]), @@ -879,9 +912,10 @@ class CallbackModule(default.CallbackModule): is_shell = task_args.pop('_uses_shell', False) if is_shell and task_name == 'command': task_name = 'shell' + # win_shell doesn't use _uses_shell. raw_params = task_args.pop('_raw_params', '').split('\n') # If there's just a single line, go ahead and print it - if len(raw_params) == 1 and task_name in ('shell', 'command'): + if len(raw_params) == 1 and task_name in STREAMING_ACTIONS: task_name = '{name}: {command}'.format( name=task_name, command=raw_params[0]) diff --git a/zuul/ansible/base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 b/zuul/ansible/base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 new file mode 100644 index 0000000000..35f79b57ce --- /dev/null +++ b/zuul/ansible/base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1 @@ -0,0 +1,255 @@ +# This is based on https://github.com/ansible-collections/ansible.windows/blob/2.5.0/plugins/module_utils/Process.psm1 +# Copyright (c) 2020 Ansible Project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +#AnsibleRequires -CSharpUtil Ansible.Zuul.Win.Common +#AnsibleRequires -CSharpUtil Ansible.Zuul.Win.Command.Process + +Function Resolve-ExecutablePath { + <# + .SYNOPSIS + Tries to resolve the file path to a valid executable. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + $FilePath, + + [String] + $WorkingDirectory + ) + + # Ensure the path has an extension set, default to .exe + if (-not [IO.Path]::HasExtension($FilePath)) { + $FilePath = "$FilePath.exe" + } + + # See the if path is resolvable using the normal PATH logic. Also resolves absolute paths and relative paths if + # they exist. + $command = @(Get-Command -Name $FilePath -CommandType Application -ErrorAction SilentlyContinue) + if ($command) { + $command[0].Path + return + } + + # If -WorkingDirectory is specified, check if the path is relative to that + if ($WorkingDirectory) { + $file = $PSCmdlet.GetUnresolvedProviderPathFromPSPath((Join-Path -Path $WorkingDirectory -ChildPath $FilePath)) + if (Test-Path -LiteralPath $file) { + $file + return + } + } + + # Just hope for the best and use whatever was provided. + $FilePath +} + +Function ConvertFrom-EscapedArgument { + <# + .SYNOPSIS + Extract individual arguments from a command line string. + + .PARAMETER InputObject + The command line string to extract the arguments from. + #> + [CmdletBinding()] + [OutputType([String])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [String[]] + $InputObject + ) + + process { + foreach ($command in $InputObject) { + # CommandLineToArgv treats \" slightly different for the first argument for some reason (probably because + # it expects it to be a filepath). We add a dummy value to ensure it splits the args in the same way as + # each other and just discard that first arg in the output. + $command = "a $command" + [Ansible.Zuul.Win.Command.Process.ProcessUtil]::CommandLineToArgv($command) | Select-Object -Skip 1 + } + } +} + +Function ConvertTo-EscapedArgument { + <# + .SYNOPSIS + Escapes an argument value so it can be used in a call to CreateProcess. + + .PARAMETER InputObject + The argument(s) to escape. + #> + [CmdletBinding()] + [OutputType([String])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowEmptyString()] + [AllowNull()] + [String[]] + $InputObject + ) + + process { + if (-not $InputObject) { + return '""' + } + + foreach ($argument in $InputObject) { + if (-not $argument) { + return '""' + } + elseif ($argument -notmatch '[\s"]') { + return $argument + } + + # Replace any double quotes in an argument with '\"' + $argument = $argument -replace '"', '\"' + + # Double up on any '\' chars that preceded '\"' + $argument = $argument -replace '(\\+)\\"', '$1$1\"' + + # Double up '\' at the end of the argument so it doesn't escape end quote. + $argument = $argument -replace '(\\+)$', '$1$1' + + # Finally wrap the entire argument in double quotes now we've escaped the double quotes within + '"{0}"' -f $argument + } + } +} + +Function Start-AnsibleWindowsProcess { + <# + .SYNOPSIS + Start a process and wait for it to finish. + + .PARAMETER FilePath + The file to execute. + + .PARAMETER ArgumentList + Arguments to execute, these will be escaped so the literal value is used. + + .PARAMETER CommandLine + The raw command line to call with CreateProcess. These values are not escaped for you so use at your own risk. + + .PARAMETER WorkingDirectory + The working directory to set on the new process, defaults to the current working dir. + + .PARAMETER Environment + Override the environment to set for the new process, if not set then the current environment will be used. + + .PARAMETER InputObject + A string or byte[] array to send to the process' stdin when it has started. + + .PARAMETER OutputEncodingOverride + The encoding name to use when reading the stdout/stderr of the process. Defaults to utf-8 if not set. + + .PARAMETER WaitChildren + Whether to wait for any child process spawned to finish before returning. This only works on Windows hosts on + Server 2012/Windows 8 or newer. + + .OUTPUTS + [PSCustomObject]@{ + Command = The final command used to start the process + Stdout = The stdout of the process + Stderr = The stderr of the process + ExitCode = The return code from the process + } + #> + [CmdletBinding(DefaultParameterSetName = 'ArgumentList')] + [OutputType('Ansible.Zuul.Win.Command.Process.Info')] + param ( + [Parameter(Mandatory = $true, ParameterSetName = 'ArgumentList')] + [Parameter(ParameterSetName = 'CommandLine')] + [String] + $FilePath, + + [Parameter(ParameterSetName = 'ArgumentList')] + [String[]] + $ArgumentList, + + [Parameter(Mandatory = $true, ParameterSetName = 'CommandLine')] + [String] + $CommandLine, + + [String] + # Default to the PowerShell location and not the process location. + $WorkingDirectory = (Get-Location -PSProvider FileSystem), + + [Collections.IDictionary] + $Environment, + + [Object] + $InputObject, + + [String] + [Alias('OutputEncoding')] + $OutputEncodingOverride, + + [Switch] + $WaitChildren, + + [String] + $ZuulLogId, + + [String] + $ZuulLogPath, + + [UInt32] + $ZuulOutputMaxBytes + ) + + if ($WorkingDirectory) { + if (-not (Test-Path -LiteralPath $WorkingDirectory)) { + Write-Error -Message "Could not find specified -WorkingDirectory '$WorkingDirectory'" + return + } + } + + if ($FilePath) { + $applicationName = $FilePath + } + else { + # If -FilePath is not set then -CommandLine must have been used. Select the path based on the first entry. + $applicationName = [Ansible.Zuul.Win.Command.Process.ProcessUtil]::CommandLineToArgv($CommandLine)[0] + } + $applicationName = Resolve-ExecutablePath -FilePath $applicationName -WorkingDirectory $WorkingDirectory + + # When -ArgumentList is used, we need to escape each argument, including the FilePath to build our CommandLine. + if ($PSCmdlet.ParameterSetName -eq 'ArgumentList') { + $CommandLine = ConvertTo-EscapedArgument -InputObject $applicationName + if ($ArgumentList.Count) { + $escapedArguments = @($ArgumentList | ConvertTo-EscapedArgument) + $CommandLine += " $($escapedArguments -join ' ')" + } + } + + $stdin = $null + if ($InputObject) { + if ($InputObject -is [byte[]]) { + $stdin = $InputObject + } + elseif ($InputObject -is [string]) { + $stdin = [Text.Encoding]::UTF8.GetBytes($InputObject) + } + else { + Write-Error -Message "InputObject must be a string or byte[]" + return + } + } + + $res = [Ansible.Zuul.Win.Command.Process.ProcessUtil]::CreateProcess($applicationName, $CommandLine, $WorkingDirectory, $Environment, $stdin, $OutputEncodingOverride, $WaitChildren, $ZuulLogId, $ZuulLogPath, $ZuulOutputMaxBytes) + + [PSCustomObject]@{ + PSTypeName = 'Ansible.Zuul.Win.Command.Process.Info' + Command = $CommandLine + Stdout = $res.StandardOut + Stderr = $res.StandardError + ExitCode = $res.ExitCode + } +} + +$exportMembers = @{ + Function = 'ConvertFrom-EscapedArgument', 'ConvertTo-EscapedArgument', 'Resolve-ExecutablePath', 'Start-AnsibleWindowsProcess' +} +Export-ModuleMember @exportMembers diff --git a/zuul/ansible/base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 b/zuul/ansible/base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 new file mode 100644 index 0000000000..8428e552e4 --- /dev/null +++ b/zuul/ansible/base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1 @@ -0,0 +1,112 @@ +# This is based on: https://github.com/ansible/ansible/blob/v2.16.14/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 +# Copyright (c) 2017 Ansible Project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +#AnsibleRequires -CSharpUtil Ansible.Zuul.Win.Common +#AnsibleRequires -CSharpUtil Ansible.Zuul.Win.Shell.Process + +Function Get-ExecutablePath { + <# + .SYNOPSIS + Get's the full path to an executable, will search the directory specified or ones in the PATH env var. + + .PARAMETER executable + [String]The executable to search for. + + .PARAMETER directory + [String] If set, the directory to search in. + + .OUTPUT + [String] The full path the executable specified. + #> + Param( + [String]$executable, + [String]$directory = $null + ) + + # we need to add .exe if it doesn't have an extension already + if (-not [System.IO.Path]::HasExtension($executable)) { + $executable = "$($executable).exe" + } + $full_path = [System.IO.Path]::GetFullPath($executable) + + if ($full_path -ne $executable -and $directory -ne $null) { + $file = Get-Item -LiteralPath "$directory\$executable" -Force -ErrorAction SilentlyContinue + } + else { + $file = Get-Item -LiteralPath $executable -Force -ErrorAction SilentlyContinue + } + + if ($null -ne $file) { + $executable_path = $file.FullName + } + else { + $executable_path = [Ansible.Zuul.Win.Shell.Process.ProcessUtil]::SearchPath($executable) + } + return $executable_path +} + +Function Run-Command { + <# + .SYNOPSIS + Run a command with the CreateProcess API and return the stdout/stderr and return code. + + .PARAMETER command + The full command, including the executable, to run. + + .PARAMETER working_directory + The working directory to set on the new process, will default to the current working dir. + + .PARAMETER stdin + A string to sent over the stdin pipe to the new process. + + .PARAMETER environment + A hashtable of key/value pairs to run with the command. If set, it will replace all other env vars. + + .PARAMETER output_encoding_override + The character encoding name for decoding stdout/stderr output of the process. + + .OUTPUT + [Hashtable] + [String]executable - The full path to the executable that was run + [String]stdout - The stdout stream of the process + [String]stderr - The stderr stream of the process + [Int32]rc - The return code of the process + #> + Param( + [string]$command, + [string]$working_directory = $null, + [string]$stdin = "", + [hashtable]$environment = @{}, + [string]$output_encoding_override = $null, + [String]$zuul_log_id, + [String]$zuul_log_path, + [UInt32]$zuul_output_max_bytes + ) + + # need to validate the working directory if it is set + if ($working_directory) { + # validate working directory is a valid path + if (-not (Test-Path -LiteralPath $working_directory)) { + throw "invalid working directory path '$working_directory'" + } + } + + # lpApplicationName needs to be the full path to an executable, we do this + # by getting the executable as the first arg and then getting the full path + $arguments = [Ansible.Zuul.Win.Shell.Process.ProcessUtil]::ParseCommandLine($command) + $executable = Get-ExecutablePath -executable $arguments[0] -directory $working_directory + + # run the command and get the results + $command_result = [Ansible.Zuul.Win.Shell.Process.ProcessUtil]::CreateProcess($executable, $command, $working_directory, $environment, $stdin, $output_encoding_override, $zuul_log_id, $zuul_log_path, $zuul_output_max_bytes) + + return , @{ + executable = $executable + stdout = $command_result.StandardOut + stderr = $command_result.StandardError + rc = $command_result.ExitCode + } +} + +# this line must stay at the bottom to ensure all defined module parts are exported +Export-ModuleMember -Function Get-ExecutablePath, Run-Command diff --git a/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Command.Process.cs b/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Command.Process.cs new file mode 100644 index 0000000000..ab5af03dec --- /dev/null +++ b/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Command.Process.cs @@ -0,0 +1,997 @@ +// This is based on https://github.com/ansible-collections/ansible.windows/blob/2.5.0/plugins/module_utils/Process.cs +// That file does not have a license header, so it is presumed to use +// the GPLv3 as noted in the root of the repo. +// GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +// Zuul note: This file, originating in the ansible.windows +// collection, is similar to a file in ansible-core, but this file is +// only used by the win_command module while the win_shell module uses +// the version in core. Therefore, in Zuul, this file and namespace +// have adopted the name "Ansible.Zuul.Win.Command.Process" to +// delineate that this is the Zuul version of the Process module used +// by win_command. + +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +using Ansible.Zuul.Win.Common; + +//TypeAccelerator -Name Ansible.Zuul.Win.Command.Process.ProcessInformation -TypeName ProcessInformation +//TypeAccelerator -Name Ansible.Zuul.Win.Command.Process.ProcessUtil -TypeName ProcessUtil +//TypeAccelerator -Name Ansible.Zuul.Win.Command.Process.Result -TypeName Result +//TypeAccelerator -Name Ansible.Zuul.Win.Command.Process.SecurityAttributes -TypeName SecurityAttributes +//TypeAccelerator -Name Ansible.Zuul.Win.Command.Process.StartupInfo -TypeName StartupInfo + +namespace Ansible.Zuul.Win.Command.Process +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_ASSOCIATE_COMPLETION_PORT + { + public IntPtr CompletionKey; + public IntPtr CompletionPort; + } + + [StructLayout(LayoutKind.Sequential)] + public class SECURITY_ATTRIBUTES + { + public UInt32 nLength; + public IntPtr lpSecurityDescriptor; + public bool bInheritHandle = false; + public SECURITY_ATTRIBUTES() + { + nLength = (UInt32)Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public class STARTUPINFOW + { + public UInt32 cb; + public IntPtr lpReserved; + [MarshalAs(UnmanagedType.LPWStr)] public string lpDesktop; + [MarshalAs(UnmanagedType.LPWStr)] public string lpTitle; + public UInt32 dwX; + public UInt32 dwY; + public UInt32 dwXSize; + public UInt32 dwYSize; + public UInt32 dwXCountChars; + public UInt32 dwYCountChars; + public UInt32 dwFillAttribute; + public StartupInfoFlags dwFlags; + public UInt16 wShowWindow; + public UInt16 cbReserved2; + public IntPtr lpReserved2; + public SafeHandle hStdInput = new SafeNativeHandle(IntPtr.Zero, false); + public SafeHandle hStdOutput = new SafeNativeHandle(IntPtr.Zero, false); + public SafeHandle hStdError = new SafeNativeHandle(IntPtr.Zero, false); + + public STARTUPINFOW() + { + cb = (UInt32)Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public class STARTUPINFOEX + { + public STARTUPINFOW startupInfo; + public SafeHandle lpAttributeList = new SafeNativeHandle(IntPtr.Zero, false); + public STARTUPINFOEX() + { + startupInfo = new STARTUPINFOW(); + startupInfo.cb = (UInt32)Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + + [Flags] + public enum DuplicateHandleOptions : uint + { + NONE = 0x0000000, + DUPLICATE_CLOSE_SOURCE = 0x00000001, + DUPLICATE_SAME_ACCESS = 0x00000002, + } + + public enum JobObjectInformationClass : uint + { + JobObjectAssociateCompletionPortInformation = 7, + } + + [Flags] + public enum StartupInfoFlags : uint + { + STARTF_USESHOWWINDOW = 0x00000001, + USESTDHANDLES = 0x00000100, + } + + [Flags] + public enum HandleFlags : uint + { + None = 0, + INHERIT = 1 + } + } + + internal class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AssignProcessToJobObject( + SafeHandle hJob, + IntPtr hProcess); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle( + IntPtr hObject); + + [DllImport("shell32.dll", SetLastError = true)] + public static extern SafeMemoryBuffer CommandLineToArgvW( + [MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, + out int pNumArgs); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern SafeNativeHandle CreateIoCompletionPort( + IntPtr FileHandle, + IntPtr ExistingCompletionPort, + UIntPtr CompletionKey, + UInt32 NumberOfConcurrentThreads); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern SafeNativeHandle CreateJobObjectW( + IntPtr lpJobAttributes, + string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CreatePipe( + out SafeFileHandle hReadPipe, + out SafeFileHandle hWritePipe, + NativeHelpers.SECURITY_ATTRIBUTES lpPipeAttributes, + UInt32 nSize); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateProcessW( + [MarshalAs(UnmanagedType.LPWStr)] string lpApplicationName, + StringBuilder lpCommandLine, + SafeMemoryBuffer lpProcessAttributes, + SafeMemoryBuffer lpThreadAttributes, + bool bInheritHandles, + ProcessCreationFlags dwCreationFlags, + SafeMemoryBuffer lpEnvironment, + [MarshalAs(UnmanagedType.LPWStr)] string lpCurrentDirectory, + NativeHelpers.STARTUPINFOEX lpStartupInfo, + out NativeHelpers.PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool TerminateProcess( + SafeHandle hProcess, + UInt32 lpExitCode); + + [DllImport("kernel32.dll")] + public static extern void DeleteProcThreadAttributeList( + IntPtr lpAttributeList); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool DuplicateHandle( + SafeHandle hSourceProcessHandle, + SafeHandle hSourceHandle, + SafeHandle hTargetProcessHandle, + out IntPtr lpTargetHandle, + UInt32 dwDesiredAccess, + bool bInheritHandle, + NativeHelpers.DuplicateHandleOptions dwOptions); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll")] + public static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetExitCodeProcess( + SafeHandle hProcess, + out UInt32 lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetQueuedCompletionStatus( + SafeHandle CompletionPort, + out UInt32 lpNumberOfBytesTransferred, + out UIntPtr lpCompletionKey, + out IntPtr lpOverlapped, + UInt32 dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool InitializeProcThreadAttributeList( + IntPtr lpAttributeList, + Int32 dwAttributeCount, + UInt32 dwFlags, + ref IntPtr lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern SafeNativeHandle OpenProcess( + Int32 dwDesiredAccess, + bool bInheritHandle, + Int32 dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern UInt32 ResumeThread( + SafeHandle hThread); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetConsoleCP( + UInt32 wCodePageID); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetConsoleOutputCP( + UInt32 wCodePageID); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetHandleInformation( + SafeHandle hObject, + NativeHelpers.HandleFlags dwMask, + NativeHelpers.HandleFlags dwFlags); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetInformationJobObject( + SafeHandle hJob, + NativeHelpers.JobObjectInformationClass JobObjectInformationClass, + IntPtr lpJobObjectInformation, + Int32 cbJobObjectInformationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool UpdateProcThreadAttribute( + SafeHandle lpAttributeList, + UInt32 dwFlags, + UIntPtr Attribute, + SafeHandle lpValue, + UIntPtr cbSize, + IntPtr lpPreviousValue, + IntPtr lpReturnSize); + + [DllImport("kernel32.dll")] + public static extern UInt32 WaitForSingleObject( + SafeHandle hHandle, + UInt32 dwMilliseconds); + } + + internal class SafeDuplicateHandle : SafeHandleZeroOrMinusOneIsInvalid + { + private readonly SafeHandle _process; + private readonly bool _ownsHandle; + + public SafeDuplicateHandle(IntPtr handle, SafeHandle process) : this(handle, process, true) { } + + public SafeDuplicateHandle(IntPtr handle, SafeHandle process, bool ownsHandle) : base(true) + { + SetHandle(handle); + _process = process; + _ownsHandle = ownsHandle; + } + + protected override bool ReleaseHandle() + { + if (_ownsHandle) + { + // Cannot pass this SafeHandle object to DuplicateHandle as it + // will appeared as closed/invalid already. Use a temporary + // SafeHandle that is set not to dispose itself. + ProcessUtil.DuplicateHandle( + _process, + new SafeNativeHandle(handle, false), + null, + 0, + false, + NativeHelpers.DuplicateHandleOptions.DUPLICATE_CLOSE_SOURCE, + false); + _process.Dispose(); + } + return true; + } + } + + internal class SafeMemoryBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeMemoryBuffer() : base(true) { } + public SafeMemoryBuffer(int cb) : base(true) + { + base.SetHandle(Marshal.AllocHGlobal(cb)); + } + public SafeMemoryBuffer(IntPtr handle) : base(true) + { + base.SetHandle(handle); + } + + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; + } + } + + internal class SafeProcThreadAttribute : SafeHandleZeroOrMinusOneIsInvalid + { + internal List values = new List(); + + public SafeProcThreadAttribute() : base(true) { } + public SafeProcThreadAttribute(IntPtr preexistingHandle, bool ownsHandle) : base(ownsHandle) + { + SetHandle(preexistingHandle); + } + + public void AddValue(SafeHandle value) + { + values.Add(value); + } + + protected override bool ReleaseHandle() + { + foreach (SafeHandle val in values) + { + val.Dispose(); + } + + NativeMethods.DeleteProcThreadAttributeList(handle); + Marshal.FreeHGlobal(handle); + + return true; + } + } + + [Flags] + public enum ProcessCreationFlags : uint + { + None = 0x00000000, + DebugProcess = 0x00000001, + DebugOnlyThisProcess = 0x00000002, + CreateSuspended = 0x00000004, + DetachedProcess = 0x00000008, + CreateNewConsole = 0x00000010, + NormalPriorityClass = 0x00000020, + IdlePriorityClass = 0x00000040, + HighPriorityClass = 0x00000080, + RealtimePriorityClass = 0x00000100, + CreateNewProcessGroup = 0x00000200, + CreateUnicodeEnvironment = 0x00000400, + CreateSeparateWowVdm = 0x00000800, + CreateSharedWowVdm = 0x00001000, + CreateForceDos = 0x00002000, + BelowNormalPriorityClass = 0x00004000, + AboveNormalPriorityClass = 0x00008000, + InheritParentAffinity = 0x00010000, + InheritCallerPriority = 0x00020000, + CreateProtectedProcess = 0x00040000, + ExtendedStartupInfoPresent = 0x00080000, + ProcessModeBackgroundBegin = 0x00100000, + ProcessModeBackgroundEnd = 0x00200000, + CreateSecureProcess = 0x00400000, + CreateBreakawayFromJob = 0x01000000, + CreatePreserveCodeAuthzLevel = 0x02000000, + CreateDefaultErrorMode = 0x04000000, + CreateNoWindow = 0x08000000, + ProfileUser = 0x10000000, + ProfileKernel = 0x20000000, + ProfileServer = 0x40000000, + CreateIgnoreSystemDefault = 0x80000000, + } + + public class SafeNativeHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeNativeHandle() : base(true) { } + public SafeNativeHandle(IntPtr handle) : this(handle, true) { } + public SafeNativeHandle(IntPtr handle, bool ownsHandle) : base(ownsHandle) { this.handle = handle; } + + protected override bool ReleaseHandle() + { + return NativeMethods.CloseHandle(handle); + } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2} - 0x{2:X8})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class Result + { + public string StandardOut { get; internal set; } + public string StandardError { get; internal set; } + public uint ExitCode { get; internal set; } + } + + public class ProcessInformation : IDisposable + { + public SafeNativeHandle Process { get; internal set; } + public SafeNativeHandle Thread { get; internal set; } + public int ProcessId { get; internal set; } + public int ThreadId { get; internal set; } + + public void Dispose() + { + if (Process != null) + Process.Dispose(); + + if (Thread != null) + Thread.Dispose(); + + GC.SuppressFinalize(this); + } + ~ProcessInformation() { Dispose(); } + } + + public class SecurityAttributes + { + public bool InheritHandle { get; set; } + // TODO: Support SecurityDescriptor at some point. + // Should it use RawSecurityDescriptor or create a Process SD class that inherits NativeObjectSecurity? + } + + public class StartupInfo + { + public string Desktop { get; set; } + public string Title { get; set; } + public ProcessWindowStyle? WindowStyle { get; set; } + public SafeHandle StandardInput { get; set; } + public SafeHandle StandardOutput { get; set; } + public SafeHandle StandardError { get; set; } + public int ParentProcess { get; set; } + + // TODO: Support PROC_THREAD_ATTRIBUTE_HANDLE_LIST + } + + public class ProcessUtil + { + /// + /// Parses a command line string into an argv array according to the Windows rules + /// + /// The command line to parse + /// An array of arguments interpreted by Windows + public static string[] CommandLineToArgv(string lpCommandLine) + { + int numArgs; + using (SafeMemoryBuffer buf = NativeMethods.CommandLineToArgvW(lpCommandLine, out numArgs)) + { + if (buf.IsInvalid) + throw new Win32Exception("Error parsing command line"); + IntPtr[] strptrs = new IntPtr[numArgs]; + Marshal.Copy(buf.DangerousGetHandle(), strptrs, 0, numArgs); + return strptrs.Select(s => Marshal.PtrToStringUni(s)).ToArray(); + } + } + + /// + /// Creates a process based on the CreateProcess API call and wait for it to complete. + /// + /// The name of the executable or batch file to execute + /// The command line to execute, typically this includes lpApplication as the first argument + /// The full path to the current directory for the process, null will have the same cwd as the calling process + /// A dictionary of key/value pairs to define the new process environment + /// A byte array to send over the stdin pipe + /// The character encoding for decoding stdout/stderr output of the process. + /// Whether to wait for any children spawned by the process to finished (Server2012 +). + /// Result object that contains the command output and return code + public static Result CreateProcess( + string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, + IDictionary environment, byte[] stdin, string outputEncoding, + bool waitChildren, string zuulLogId, string zuulLogPath, UInt32 zuulOutputMaxBytes) + { + ProcessCreationFlags creationFlags = ProcessCreationFlags.CreateSuspended | + ProcessCreationFlags.CreateUnicodeEnvironment; + StartupInfo si = new StartupInfo(); + ProcessInformation pi = null; + + SafeFileHandle stdoutRead, stdoutWrite, stderrRead, stderrWrite, stdinRead, stdinWrite; + CreateStdioPipes(si, out stdoutRead, out stdoutWrite, out stderrRead, out stderrWrite, out stdinRead, + out stdinWrite); + + using (stdoutRead) + using (stdoutWrite) + using (stderrRead) + using (stderrWrite) + using (stdinRead) + using (stdinWrite) + { + FileStream stdinStream = new FileStream(stdinWrite, FileAccess.Write); + + bool isConsole = false; + if (NativeMethods.GetConsoleWindow() == IntPtr.Zero) + { + isConsole = NativeMethods.AllocConsole(); + + // Set console input/output codepage to UTF-8 + NativeMethods.SetConsoleCP(65001); + NativeMethods.SetConsoleOutputCP(65001); + } + + try + { + pi = NativeCreateProcess(lpApplicationName, lpCommandLine, null, null, true, creationFlags, + environment, lpCurrentDirectory, si); + } + // Zuul: This exception handler is new so that we can + // output a log line with a result code in case a + // command is not found. + catch (Win32Exception e) + { + using(ZuulConsole console = new ZuulConsole(zuulLogId, zuulLogPath)) + { + console.LogExitCode((UInt32)e.NativeErrorCode); + } + throw; + } + finally + { + if (isConsole) + NativeMethods.FreeConsole(); + } + + using (pi) + { + return WaitProcess(stdoutRead, stdoutWrite, stderrRead, stderrWrite, stdinStream, stdin, pi, + outputEncoding, waitChildren, zuulLogId, zuulLogPath, zuulOutputMaxBytes); + } + } + } + + /// + /// Wrapper around the Win32 CreateProcess API for low level use. This just spawns the new process and does not + /// wait until it is complete before returning. + /// + /// The name of the executable or batch file to execute + /// The command line to execute, typically this includes applicationName as the first argument + /// SecurityAttributes to assign to the new process, set to null to use the defaults + /// SecurityAttributes to assign to the new thread, set to null to use the defaults + /// Any inheritable handles in the calling process is inherited in the new process + /// Custom creation flags to use when creating the new process + /// A dictionary of key/value pairs to define the new process environment + /// The full path to the current directory for the process, null will have the same cwd as the calling process + /// Custom StartupInformation to use when creating the new process + /// ProcessInformation containing a handle to the process and main thread as well as the pid/tid. + public static ProcessInformation NativeCreateProcess(string applicationName, string commandLine, + SecurityAttributes processAttributes, SecurityAttributes threadAttributes, bool inheritHandles, + ProcessCreationFlags creationFlags, IDictionary environment, string currentDirectory, StartupInfo startupInfo) + { + // We always have the extended version present. + creationFlags |= ProcessCreationFlags.ExtendedStartupInfoPresent; + + // $null from PowerShell ends up as an empty string, we need to convert back as an empty string doesn't + // make sense for these parameters + if (String.IsNullOrWhiteSpace(applicationName)) + applicationName = null; + + if (String.IsNullOrWhiteSpace(currentDirectory)) + currentDirectory = null; + + NativeHelpers.STARTUPINFOEX si = new NativeHelpers.STARTUPINFOEX(); + if (!String.IsNullOrWhiteSpace(startupInfo.Desktop)) + si.startupInfo.lpDesktop = startupInfo.Desktop; + + if (!String.IsNullOrWhiteSpace(startupInfo.Title)) + si.startupInfo.lpTitle = startupInfo.Title; + + if (startupInfo.WindowStyle != null) + { + switch (startupInfo.WindowStyle) + { + case ProcessWindowStyle.Normal: + si.startupInfo.wShowWindow = 1; // SW_SHOWNORMAL + break; + case ProcessWindowStyle.Hidden: + si.startupInfo.wShowWindow = 0; // SW_HIDE + break; + case ProcessWindowStyle.Minimized: + si.startupInfo.wShowWindow = 6; // SW_MINIMIZE + break; + case ProcessWindowStyle.Maximized: + si.startupInfo.wShowWindow = 3; // SW_MAXIMIZE + break; + } + si.startupInfo.dwFlags |= NativeHelpers.StartupInfoFlags.STARTF_USESHOWWINDOW; + } + + si.lpAttributeList = CreateProcThreadAttributes(startupInfo); + + NativeHelpers.PROCESS_INFORMATION pi = new NativeHelpers.PROCESS_INFORMATION(); + using (SafeHandle stdinHandle = PrepareStdioHandle(startupInfo.StandardInput, startupInfo)) + using (SafeHandle stdoutHandle = PrepareStdioHandle(startupInfo.StandardOutput, startupInfo)) + using (SafeHandle stderrHandle = PrepareStdioHandle(startupInfo.StandardError, startupInfo)) + using (SafeMemoryBuffer lpProcessAttr = CreateSecurityAttributes(processAttributes)) + using (SafeMemoryBuffer lpThreadAttributes = CreateSecurityAttributes(threadAttributes)) + using (SafeMemoryBuffer lpEnvironment = CreateEnvironmentPointer(environment)) + { + si.startupInfo.hStdInput = stdinHandle; + si.startupInfo.hStdOutput = stdoutHandle; + si.startupInfo.hStdError = stderrHandle; + if ( + si.startupInfo.hStdInput.DangerousGetHandle() != IntPtr.Zero || + si.startupInfo.hStdOutput.DangerousGetHandle() != IntPtr.Zero || + si.startupInfo.hStdError.DangerousGetHandle() != IntPtr.Zero + ) + { + si.startupInfo.dwFlags |= NativeHelpers.StartupInfoFlags.USESTDHANDLES; + } + + StringBuilder commandLineBuff = new StringBuilder(commandLine); + if (!NativeMethods.CreateProcessW(applicationName, commandLineBuff, lpProcessAttr, lpThreadAttributes, + inheritHandles, creationFlags, lpEnvironment, currentDirectory, si, out pi)) + { + throw new Win32Exception("CreateProcessW() failed"); + } + } + + return new ProcessInformation + { + Process = new SafeNativeHandle(pi.hProcess), + Thread = new SafeNativeHandle(pi.hThread), + ProcessId = pi.dwProcessId, + ThreadId = pi.dwThreadId, + }; + } + + /// + /// Resume a suspended thread. + /// + /// The thread handle to resume + public static void ResumeThread(SafeHandle thread) + { + if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF) + throw new Win32Exception("ResumeThread() failed"); + } + + /// + /// Gets the exit code for the specified process handle. + /// + /// The process handle to get the exit code for. + /// The process exit code. + public static UInt32 GetProcessExitCode(SafeHandle processHandle) + { + NativeMethods.WaitForSingleObject(processHandle, 0xFFFFFFFF); + + UInt32 exitCode; + if (!NativeMethods.GetExitCodeProcess(processHandle, out exitCode)) + throw new Win32Exception("GetExitCodeProcess() failed"); + return exitCode; + } + + internal static void CreateStdioPipes(StartupInfo si, out SafeFileHandle stdoutRead, + out SafeFileHandle stdoutWrite, out SafeFileHandle stderrRead, out SafeFileHandle stderrWrite, + out SafeFileHandle stdinRead, out SafeFileHandle stdinWrite) + { + NativeHelpers.SECURITY_ATTRIBUTES pipesec = new NativeHelpers.SECURITY_ATTRIBUTES(); + pipesec.bInheritHandle = true; + + if (!NativeMethods.CreatePipe(out stdoutRead, out stdoutWrite, pipesec, 0)) + throw new Win32Exception("STDOUT pipe setup failed"); + if (!NativeMethods.SetHandleInformation(stdoutRead, NativeHelpers.HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDOUT pipe handle setup failed"); + + if (!NativeMethods.CreatePipe(out stderrRead, out stderrWrite, pipesec, 0)) + throw new Win32Exception("STDERR pipe setup failed"); + if (!NativeMethods.SetHandleInformation(stderrRead, NativeHelpers.HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDERR pipe handle setup failed"); + + if (!NativeMethods.CreatePipe(out stdinRead, out stdinWrite, pipesec, 0)) + throw new Win32Exception("STDIN pipe setup failed"); + if (!NativeMethods.SetHandleInformation(stdinWrite, NativeHelpers.HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDIN pipe handle setup failed"); + + si.StandardOutput = stdoutWrite; + si.StandardError = stderrWrite; + si.StandardInput = stdinRead; + } + + internal static SafeMemoryBuffer CreateEnvironmentPointer(IDictionary environment) + { + IntPtr lpEnvironment = IntPtr.Zero; + if (environment != null && environment.Count > 0) + { + StringBuilder environmentString = new StringBuilder(); + foreach (DictionaryEntry kv in environment) + environmentString.AppendFormat("{0}={1}\0", kv.Key, kv.Value); + environmentString.Append('\0'); + + lpEnvironment = Marshal.StringToHGlobalUni(environmentString.ToString()); + } + return new SafeMemoryBuffer(lpEnvironment); + } + + internal static SafeMemoryBuffer CreateSecurityAttributes(SecurityAttributes attributes) + { + IntPtr lpAttributes = IntPtr.Zero; + if (attributes != null) + { + NativeHelpers.SECURITY_ATTRIBUTES attr = new NativeHelpers.SECURITY_ATTRIBUTES() + { + bInheritHandle = attributes.InheritHandle, + }; + + lpAttributes = Marshal.AllocHGlobal(Marshal.SizeOf(attr)); + Marshal.StructureToPtr(attr, lpAttributes, false); + } + + return new SafeMemoryBuffer(lpAttributes); + } + + internal static SafeDuplicateHandle DuplicateHandle(SafeHandle sourceProcess, SafeHandle sourceHandle, + SafeHandle targetProcess, UInt32 access, bool inherit, NativeHelpers.DuplicateHandleOptions options, + bool ownsHandle) + { + if (targetProcess == null) + { + targetProcess = new SafeNativeHandle(IntPtr.Zero, false); + // If closing the duplicate then mark the returned handle so it doesn't try to close itself again. + ownsHandle = (options & NativeHelpers.DuplicateHandleOptions.DUPLICATE_CLOSE_SOURCE) == 0; + } + + IntPtr dup = IntPtr.Zero; + if (!NativeMethods.DuplicateHandle(sourceProcess, sourceHandle, targetProcess, out dup, access, + inherit, options)) + { + throw new Win32Exception("DuplicateHandle() failed"); + } + + return new SafeDuplicateHandle(dup, targetProcess, ownsHandle); + } + + internal static Result WaitProcess( + SafeFileHandle stdoutRead, SafeFileHandle stdoutWrite, SafeFileHandle stderrRead, + SafeFileHandle stderrWrite, FileStream stdinStream, byte[] stdin, ProcessInformation pi, + string outputEncoding, bool waitChildren, string zuulLogId, string zuulLogPath, UInt32 zuulOutputMaxBytes) + { + // Default to using UTF-8 as the output encoding, this should be a sane default for most scenarios. + outputEncoding = String.IsNullOrEmpty(outputEncoding) ? "utf-8" : outputEncoding; + Encoding encodingInstance = Encoding.GetEncoding(outputEncoding); + + // If we aren't waiting for child processes we don't care if the below fails + // Logic to wait for children is from Raymond Chen + // https://devblogs.microsoft.com/oldnewthing/20130405-00/?p=4743 + using (SafeHandle job = CreateJob(!waitChildren)) + using (SafeHandle ioPort = CreateCompletionPort(!waitChildren)) + { + // Need to assign the completion port to the job and then assigned the new process to that job. + if (waitChildren) + { + NativeHelpers.JOBOBJECT_ASSOCIATE_COMPLETION_PORT compPort = new NativeHelpers.JOBOBJECT_ASSOCIATE_COMPLETION_PORT() + { + CompletionKey = job.DangerousGetHandle(), + CompletionPort = ioPort.DangerousGetHandle(), + }; + int compPortSize = Marshal.SizeOf(compPort); + + using (SafeMemoryBuffer compPortPtr = new SafeMemoryBuffer(compPortSize)) + { + Marshal.StructureToPtr(compPort, compPortPtr.DangerousGetHandle(), false); + + if (!NativeMethods.SetInformationJobObject(job, + NativeHelpers.JobObjectInformationClass.JobObjectAssociateCompletionPortInformation, + compPortPtr.DangerousGetHandle(), compPortSize)) + { + throw new Win32Exception("Failed to set job completion port information"); + } + } + + // Server 2012/Win 8 introduced the ability to nest jobs. Older versions will fail with + // ERROR_ACCESS_DENIED but we can't do anything about that except not wait for children. + if (!NativeMethods.AssignProcessToJobObject(job, pi.Process.DangerousGetHandle())) + throw new Win32Exception("Failed to assign new process to completion watcher job"); + } + + // Start the process and get the output. + ResumeThread(pi.Thread); + + FileStream stdoutFS = new FileStream(stdoutRead, FileAccess.Read, 4096); + StreamReader stdout = new ZuulStreamReader(stdoutFS, encodingInstance, true, 4096); + stdoutWrite.Close(); + + FileStream stderrFS = new FileStream(stderrRead, FileAccess.Read, 4096); + StreamReader stderr = new ZuulStreamReader(stderrFS, encodingInstance, true, 4096); + stderrWrite.Close(); + + if (stdin != null) + stdinStream.Write(stdin, 0, stdin.Length); + stdinStream.Close(); + + string stdoutStr, stderrStr = null; + // Zuul: We add the process argument here so that our + // follower can kill the process if it emits too much + // output. + GetProcessOutput(stdout, stderr, pi.Process, zuulLogId, zuulLogPath, zuulOutputMaxBytes, out stdoutStr, out stderrStr); + UInt32 rc = GetProcessExitCode(pi.Process); + + if (waitChildren) + { + // If the caller wants to wait for all child processes to finish, we continue to poll the job + // until it receives JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO (4). + UInt32 completionCode = 0xFFFFFFFF; + UIntPtr completionKey; + IntPtr overlapped; + + while (NativeMethods.GetQueuedCompletionStatus(ioPort, out completionCode, + out completionKey, out overlapped, 0xFFFFFFFF) && completionCode != 4) { } + } + + return new Result + { + StandardOut = stdoutStr, + StandardError = stderrStr, + ExitCode = rc + }; + } + } + + // Zuul: This method replaces the original + internal static void GetProcessOutput(StreamReader stdoutStream, StreamReader stderrStream, SafeNativeHandle process, string zuulLogId, string zuulLogPath, UInt32 zuulOutputMaxBytes, out string stdout, out string stderr) + { + StreamFollower sf = new StreamFollower(process, stdoutStream, stderrStream, zuulLogId, zuulLogPath, zuulOutputMaxBytes); + sf.Follow(); + sf.Join(); + stdout = sf.outLogBytes.ToString(); + stderr = sf.errLogBytes.ToString(); + sf.LogExitCode(GetProcessExitCode(process)); + sf.Dispose(); + } + + private static SafeHandle CreateJob(bool ignoreErrors) + { + SafeNativeHandle job = NativeMethods.CreateJobObjectW(IntPtr.Zero, null); + if (job.IsInvalid && !ignoreErrors) + throw new Win32Exception("Failed to create job object"); + + return job; + } + + private static SafeHandle CreateCompletionPort(bool ignoreErrors) + { + SafeNativeHandle ioPort = NativeMethods.CreateIoCompletionPort((IntPtr)(-1), IntPtr.Zero, + UIntPtr.Zero, 1); + + if (ioPort.IsInvalid && !ignoreErrors) + throw new Win32Exception("Failed to create IoCompletionPort"); + + return ioPort; + } + + private static SafeHandle CreateProcThreadAttributes(StartupInfo startupInfo) + { + int count = 0; + if (startupInfo.ParentProcess > 0) + { + count++; + } + + if (count == 0) + { + return new SafeNativeHandle(IntPtr.Zero, false); + } + + SafeProcThreadAttribute attr = InitializeProcThreadAttributeList(count); + try + { + if (startupInfo.ParentProcess > 0) + { + SafeNativeHandle parentProcess = OpenProcess(startupInfo.ParentProcess, + 0x00000080, // PROCESS_CREATE_PROCESS + false); + attr.AddValue(parentProcess); + + SafeMemoryBuffer val = new SafeMemoryBuffer(IntPtr.Size); + attr.AddValue(val); + + Marshal.WriteIntPtr(val.DangerousGetHandle(), parentProcess.DangerousGetHandle()); + UpdateProcThreadAttribute(attr, + 0x00020000, // PROC_THREAD_ATTRIBUTE_PARENT_PROCESS + val, + IntPtr.Size); + } + } + catch + { + attr.Dispose(); + throw; + } + + return attr; + } + + private static SafeProcThreadAttribute InitializeProcThreadAttributeList(int count) + { + IntPtr size = IntPtr.Zero; + NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, count, 0, ref size); + + IntPtr h = Marshal.AllocHGlobal((int)size); + try + { + if (!NativeMethods.InitializeProcThreadAttributeList(h, count, 0, ref size)) + throw new Win32Exception("Failed to create process thread attribute list"); + + return new SafeProcThreadAttribute(h, true); + } + catch + { + Marshal.FreeHGlobal(h); + throw; + } + } + + private static SafeNativeHandle OpenProcess(int processId, int access, bool inherit) + { + SafeNativeHandle proc = NativeMethods.OpenProcess(access, inherit, processId); + if (proc.DangerousGetHandle() == IntPtr.Zero) + { + throw new Win32Exception(string.Format( + "OpenProcess(0x{0:X8}, {1}, {2}) failed", + access, inherit, processId)); + } + + return proc; + } + + private static SafeHandle PrepareStdioHandle(SafeHandle handle, StartupInfo startupInfo) + { + if (handle == null || handle.DangerousGetHandle() == IntPtr.Zero) + return new SafeNativeHandle(IntPtr.Zero, false); + + if (startupInfo.ParentProcess > 0) + { + // The handle needs to be duplicated into the target process so + // it can be inherited. + SafeNativeHandle currentProcess = new SafeNativeHandle(NativeMethods.GetCurrentProcess(), false); + SafeNativeHandle targetProcess = OpenProcess(startupInfo.ParentProcess, + 0x00000040, // PROCESS_DUP_HANDLE + false); + + return DuplicateHandle(currentProcess, handle, targetProcess, 0, true, + NativeHelpers.DuplicateHandleOptions.DUPLICATE_SAME_ACCESS, true); + } + else + { + // Create a copy of the handle and ensure it won't be disposed. + // The original owner is still in charge of it. + return new SafeNativeHandle(handle.DangerousGetHandle(), false); + } + } + + private static void UpdateProcThreadAttribute(SafeProcThreadAttribute attributeList, int attr, + SafeHandle value, int size) + { + if (!NativeMethods.UpdateProcThreadAttribute(attributeList, 0, (UIntPtr)attr, value, (UIntPtr)size, + IntPtr.Zero, IntPtr.Zero)) + { + throw new Win32Exception("UpdateProcThreadAttribute() failed"); + } + + attributeList.AddValue(value); + } + } +} diff --git a/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Common.cs b/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Common.cs new file mode 100644 index 0000000000..0646e0750b --- /dev/null +++ b/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Common.cs @@ -0,0 +1,308 @@ +// Copyright (c) 2016 IBM Corp. +// Copyright (C) 2025 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 . + +using Microsoft.Win32.SafeHandles; +using System.Runtime.InteropServices; +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Text.RegularExpressions; +using System.Xml; + +//AssemblyReference -Name netstandard.dll +//AssemblyReference -Name System.Xml.dll +//AssemblyReference -Name System.Xml.ReaderWriter.dll + +namespace Ansible.Zuul.Win.Common +{ + internal class NativeMethods + { + // The collections Process file includes this, but not the + // core Process file, so we go aheand and import it ourselves. + // From + // https://github.com/ansible-collections/ansible.windows/blob/2.5.0/plugins/module_utils/Process.cs + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool TerminateProcess( + SafeHandle hProcess, + UInt32 lpExitCode); + } + + // From + // https://github.com/ansible-collections/ansible.windows/blob/2.5.0/plugins/module_utils/Process.cs + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2} - 0x{2:X8})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class ZuulStreamReader : StreamReader + { + private StringBuilder sb = new StringBuilder(); + + public ZuulStreamReader(Stream stream, Encoding enc, bool order, Int32 size) : base(stream, enc, order, size) {} + + // This overrides the base class to include the newline sequence. + public override string ReadLine() + { + while (true) + { + int c = Read(); + if (c == -1) + { + if (sb.Length == 0) + { + return null; + } + string ret = sb.ToString(); + sb = new StringBuilder(); + return ret; + } + char ch = (char)c; + sb.Append(ch); + if (ch == '\r' && Peek() == '\n') + { + sb.Append((char)Read()); + } + if (ch == '\r' || ch == '\n') + { + string ret = sb.ToString(); + sb = new StringBuilder(); + return ret; + } + } + } + } + + internal class ZuulConsole : IDisposable + { + private StreamWriter logFile; + + public ZuulConsole(string zuulLogId, string zuulLogPath) + { + if (zuulLogId == "in-loop-ignore") + { + logFile = null; + } + else if (zuulLogId == "skip") + { + logFile = null; + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(zuulLogPath)); + logFile = new StreamWriter(zuulLogPath); + } + } + + public void Dispose() { + if (logFile != null) + { + logFile.Dispose(); + } + } + + public void AddLine(string 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. + if (logFile != null) { + lock(logFile) { + string ts = DateTime.Now.ToString("yyy-MM-dd HH:mm:ss.ffffff"); + string outln = string.Format("{0} | {1}", ts, ln); + logFile.Write(outln); + logFile.Flush(); + } + } + } + public void LogExitCode(UInt32 rc) + { + AddLine(string.Format("[Zuul] Task exit code: {0}\n", rc)); + } + } + internal class StreamFollower : IDisposable + { + // Unlike the python equivalent, we never supported combining + // output and error streams, so this class always uses both. + private SafeHandle process; + private string zuulLogId; + private string zuulLogPath; + private StreamReader outStream; + private StreamReader errStream; + private UInt32 outputMaxBytes; + // Lists to save stdout/stderr log lines in as we collect them + public StringBuilder outLogBytes; + public StringBuilder errLogBytes; + private Thread stdoutThread; + private Thread stderrThread; + // Total size in bytes of all log and stderr_log lines + private UInt32 logSize; + private ZuulConsole console; + private static Regex cliXmlRegex = new Regex("(?)(?.*)"); + + public StreamFollower(SafeHandle process, StreamReader outStream, + StreamReader errStream, string zuulLogId, + string zuulLogPath, UInt32 outputMaxBytes) + { + this.process = process; + this.zuulLogId = zuulLogId; + this.zuulLogPath = zuulLogPath; + this.outLogBytes = new StringBuilder(); + this.errLogBytes = new StringBuilder(); + this.outStream = outStream; + this.errStream = errStream; + this.outputMaxBytes = outputMaxBytes; + this.logSize = 0; + } + + public void Dispose() + { + if (console != null) { + console.Dispose(); + } + } + + public void Follow() + { + console = new ZuulConsole(zuulLogId, zuulLogPath); + stdoutThread = new Thread(this.FollowOut); + stdoutThread.Start(); + stderrThread = new Thread(this.FollowErr); + stderrThread.Start(); + } + + private void FollowOut() + { + FollowInner(outStream, outLogBytes); + } + + private void FollowErr() + { + FollowInner(errStream, errLogBytes); + } + + + private void FollowInner(StreamReader stream, StringBuilder logBytes) + { + // The unix/python version of this has a check that throws + // a warning if we encounter a line without a trailing + // newline. That doesn't make as much sense here, so we + // omit it. + + // These variables are used for the CLIXML handling + // (win_shell only). + bool first = true; + bool cliXml = false; + while (true) + { + string line = stream.ReadLine(); + if (line == null) + { + break; + } + + logSize += (UInt32) line.Length; + if (logSize > outputMaxBytes) + { + string msg = string.Format("[Zuul] Log output exceeded max of {0}, terminating\n", outputMaxBytes); + console.AddLine(msg); + + if (!NativeMethods.TerminateProcess(process, 1)) + { + throw new Win32Exception("TerminateProcess() failed"); + } + throw new Win32Exception(msg); + } + logBytes.Append(line); + + // Begin CLIXML handling section (win_shell only) + + // This attempts to approximate the way the win_shell + // module handles the output in win_shell.ps1. + // Because the module handles the returned structured + // output, we only apply this to the streaming logs. + if (stream == errStream) + { + try + { + if (first) + { + first = false; + if (line.Equals("#< CLIXML")) + { + // Switch on CLIXML handling for this stream. + cliXml = true; + continue; + } + } + else if (cliXml) + { + Match m = cliXmlRegex.Match(line); + if (m.Success) + { + XmlDocument xml = new XmlDocument(); + xml.LoadXml(m.Groups["clixml"].Value); + + XmlNode root = xml.DocumentElement; + XmlNodeList nodes = root.SelectNodes("*[local-name()='S' and @S='Error']"); + + foreach (XmlNode node in nodes) + { + console.AddLine(node.InnerText.Replace("_x000D__x000A_", "") + "\n"); + } + if (m.Groups["post"].Value.Length > 0) + { + console.AddLine(m.Groups["post"].Value + "\n"); + } + continue; + } + } + } catch (Exception e) + { + console.AddLine("[Zuul] Error handling CLIXML: " + e.ToString() + "\n"); + } + } + // End CLIXML handling section + console.AddLine(line); + } + } + public void Join() + { + foreach (var thread in new Thread[] { stdoutThread, stderrThread }) + { + // The original win_command will wait until the output + // streams are closed before waiting for the process + // to exit, so unlike the unix/python version, we + // don't timeout the join. + thread.Join(); + } + } + public void LogExitCode(UInt32 rc) + { + console.LogExitCode(rc); + } + } +} diff --git a/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs b/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs new file mode 100644 index 0000000000..a63d062c5f --- /dev/null +++ b/zuul/ansible/base/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs @@ -0,0 +1,506 @@ +// This is based on: https://github.com/ansible/ansible/blob/v2.16.14/lib/ansible/module_utils/csharp/Ansible.Process.cs +// That file does not have a license header, so it is presumed to use +// the GPLv3 as noted in the root of the repo. +// GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +// Zuul note: This file, originating in ansible-core, is similar to a +// file in the ansible.windows collection, but this file is only used +// by the win_shell module while the win_command module uses the +// version in the collection. Therefore, in Zuul, this file and +// namespace have adopted the name "Ansible.Zuul.Win.Shell.Process" to +// delineate that this is the Zuul version of the Process module used +// by win_shell. + +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +using Ansible.Zuul.Win.Common; + +namespace Ansible.Zuul.Win.Shell.Process +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential)] + public class SECURITY_ATTRIBUTES + { + public UInt32 nLength; + public IntPtr lpSecurityDescriptor; + public bool bInheritHandle = false; + public SECURITY_ATTRIBUTES() + { + nLength = (UInt32)Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public class STARTUPINFO + { + public UInt32 cb; + public IntPtr lpReserved; + [MarshalAs(UnmanagedType.LPWStr)] public string lpDesktop; + [MarshalAs(UnmanagedType.LPWStr)] public string lpTitle; + public UInt32 dwX; + public UInt32 dwY; + public UInt32 dwXSize; + public UInt32 dwYSize; + public UInt32 dwXCountChars; + public UInt32 dwYCountChars; + public UInt32 dwFillAttribute; + public StartupInfoFlags dwFlags; + public UInt16 wShowWindow; + public UInt16 cbReserved2; + public IntPtr lpReserved2; + public SafeFileHandle hStdInput; + public SafeFileHandle hStdOutput; + public SafeFileHandle hStdError; + public STARTUPINFO() + { + cb = (UInt32)Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public class STARTUPINFOEX + { + public STARTUPINFO startupInfo; + public IntPtr lpAttributeList; + public STARTUPINFOEX() + { + startupInfo = new STARTUPINFO(); + startupInfo.cb = (UInt32)Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [Flags] + public enum ProcessCreationFlags : uint + { + CREATE_NEW_CONSOLE = 0x00000010, + CREATE_UNICODE_ENVIRONMENT = 0x00000400, + EXTENDED_STARTUPINFO_PRESENT = 0x00080000 + } + + [Flags] + public enum StartupInfoFlags : uint + { + USESTDHANDLES = 0x00000100 + } + + [Flags] + public enum HandleFlags : uint + { + None = 0, + INHERIT = 1 + } + } + + internal class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AllocConsole(); + + [DllImport("shell32.dll", SetLastError = true)] + public static extern SafeMemoryBuffer CommandLineToArgvW( + [MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, + out int pNumArgs); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CreatePipe( + out SafeFileHandle hReadPipe, + out SafeFileHandle hWritePipe, + NativeHelpers.SECURITY_ATTRIBUTES lpPipeAttributes, + UInt32 nSize); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateProcessW( + [MarshalAs(UnmanagedType.LPWStr)] string lpApplicationName, + StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + NativeHelpers.ProcessCreationFlags dwCreationFlags, + SafeMemoryBuffer lpEnvironment, + [MarshalAs(UnmanagedType.LPWStr)] string lpCurrentDirectory, + NativeHelpers.STARTUPINFOEX lpStartupInfo, + out NativeHelpers.PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetExitCodeProcess( + SafeWaitHandle hProcess, + out UInt32 lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern uint SearchPathW( + [MarshalAs(UnmanagedType.LPWStr)] string lpPath, + [MarshalAs(UnmanagedType.LPWStr)] string lpFileName, + [MarshalAs(UnmanagedType.LPWStr)] string lpExtension, + UInt32 nBufferLength, + [MarshalAs(UnmanagedType.LPTStr)] StringBuilder lpBuffer, + out IntPtr lpFilePart); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetConsoleCP( + UInt32 wCodePageID); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetConsoleOutputCP( + UInt32 wCodePageID); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetHandleInformation( + SafeFileHandle hObject, + NativeHelpers.HandleFlags dwMask, + NativeHelpers.HandleFlags dwFlags); + + [DllImport("kernel32.dll")] + public static extern UInt32 WaitForSingleObject( + SafeWaitHandle hHandle, + UInt32 dwMilliseconds); + } + + internal class SafeMemoryBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeMemoryBuffer() : base(true) { } + public SafeMemoryBuffer(int cb) : base(true) + { + base.SetHandle(Marshal.AllocHGlobal(cb)); + } + public SafeMemoryBuffer(IntPtr handle) : base(true) + { + base.SetHandle(handle); + } + + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; + } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class Result + { + public string StandardOut { get; internal set; } + public string StandardError { get; internal set; } + public uint ExitCode { get; internal set; } + } + + public class ProcessUtil + { + /// + /// Parses a command line string into an argv array according to the Windows rules + /// + /// The command line to parse + /// An array of arguments interpreted by Windows + public static string[] ParseCommandLine(string lpCommandLine) + { + int numArgs; + using (SafeMemoryBuffer buf = NativeMethods.CommandLineToArgvW(lpCommandLine, out numArgs)) + { + if (buf.IsInvalid) + throw new Win32Exception("Error parsing command line"); + IntPtr[] strptrs = new IntPtr[numArgs]; + Marshal.Copy(buf.DangerousGetHandle(), strptrs, 0, numArgs); + return strptrs.Select(s => Marshal.PtrToStringUni(s)).ToArray(); + } + } + + /// + /// Searches the path for the executable specified. Will throw a Win32Exception if the file is not found. + /// + /// The executable to search for + /// The full path of the executable to search for + public static string SearchPath(string lpFileName) + { + StringBuilder sbOut = new StringBuilder(0); + IntPtr filePartOut = IntPtr.Zero; + UInt32 res = NativeMethods.SearchPathW(null, lpFileName, null, (UInt32)sbOut.Capacity, sbOut, out filePartOut); + if (res == 0) + { + int lastErr = Marshal.GetLastWin32Error(); + if (lastErr == 2) // ERROR_FILE_NOT_FOUND + throw new FileNotFoundException(String.Format("Could not find file '{0}'.", lpFileName)); + else + throw new Win32Exception(String.Format("SearchPathW({0}) failed to get buffer length", lpFileName)); + } + + sbOut.EnsureCapacity((int)res); + if (NativeMethods.SearchPathW(null, lpFileName, null, (UInt32)sbOut.Capacity, sbOut, out filePartOut) == 0) + throw new Win32Exception(String.Format("SearchPathW({0}) failed", lpFileName)); + + return sbOut.ToString(); + } + + /* Zuul: none of these are used by win_shell + public static Result CreateProcess(string command) + { + return CreateProcess(null, command, null, null, String.Empty); + } + + public static Result CreateProcess(string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, + IDictionary environment) + { + return CreateProcess(lpApplicationName, lpCommandLine, lpCurrentDirectory, environment, String.Empty); + } + + public static Result CreateProcess(string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, + IDictionary environment, string stdin) + { + return CreateProcess(lpApplicationName, lpCommandLine, lpCurrentDirectory, environment, stdin, null); + } + + public static Result CreateProcess(string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, + IDictionary environment, byte[] stdin) + { + return CreateProcess(lpApplicationName, lpCommandLine, lpCurrentDirectory, environment, stdin, null); + } + + public static Result CreateProcess(string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, + IDictionary environment, string stdin, string outputEncoding) + { + byte[] stdinBytes; + if (String.IsNullOrEmpty(stdin)) + stdinBytes = new byte[0]; + else + { + if (!stdin.EndsWith(Environment.NewLine)) + stdin += Environment.NewLine; + stdinBytes = new UTF8Encoding(false).GetBytes(stdin); + } + return CreateProcess(lpApplicationName, lpCommandLine, lpCurrentDirectory, environment, stdinBytes, outputEncoding); + } + */ + + /// + /// Creates a process based on the CreateProcess API call. + /// + /// The name of the executable or batch file to execute + /// The command line to execute, typically this includes lpApplication as the first argument + /// The full path to the current directory for the process, null will have the same cwd as the calling process + /// A dictionary of key/value pairs to define the new process environment + /// A byte array to send over the stdin pipe + /// The character encoding for decoding stdout/stderr output of the process. + /// Result object that contains the command output and return code + public static Result CreateProcess(string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, + IDictionary environment, byte[] stdin, string outputEncoding, + string zuulLogId, string zuulLogPath, UInt32 zuulOutputMaxBytes) + { + NativeHelpers.ProcessCreationFlags creationFlags = NativeHelpers.ProcessCreationFlags.CREATE_UNICODE_ENVIRONMENT | + NativeHelpers.ProcessCreationFlags.EXTENDED_STARTUPINFO_PRESENT; + NativeHelpers.PROCESS_INFORMATION pi = new NativeHelpers.PROCESS_INFORMATION(); + NativeHelpers.STARTUPINFOEX si = new NativeHelpers.STARTUPINFOEX(); + si.startupInfo.dwFlags = NativeHelpers.StartupInfoFlags.USESTDHANDLES; + + SafeFileHandle stdoutRead, stdoutWrite, stderrRead, stderrWrite, stdinRead, stdinWrite; + CreateStdioPipes(si, out stdoutRead, out stdoutWrite, out stderrRead, out stderrWrite, out stdinRead, + out stdinWrite); + FileStream stdinStream = new FileStream(stdinWrite, FileAccess.Write); + + // $null from PowerShell ends up as an empty string, we need to convert back as an empty string doesn't + // make sense for these parameters + if (lpApplicationName == "") + lpApplicationName = null; + + if (lpCurrentDirectory == "") + lpCurrentDirectory = null; + + using (SafeMemoryBuffer lpEnvironment = CreateEnvironmentPointer(environment)) + { + // Create console with utf-8 CP if no existing console is present + bool isConsole = false; + if (NativeMethods.GetConsoleWindow() == IntPtr.Zero) + { + isConsole = NativeMethods.AllocConsole(); + + // Set console input/output codepage to UTF-8 + NativeMethods.SetConsoleCP(65001); + NativeMethods.SetConsoleOutputCP(65001); + } + + try + { + StringBuilder commandLine = new StringBuilder(lpCommandLine); + if (!NativeMethods.CreateProcessW(lpApplicationName, commandLine, IntPtr.Zero, IntPtr.Zero, + true, creationFlags, lpEnvironment, lpCurrentDirectory, si, out pi)) + { + throw new Win32Exception("CreateProcessW() failed"); + } + } + // Zuul: This exception handler is new so that we can + // output a log line with a result code in case a + // command is not found. + catch (Win32Exception e) + { + using(ZuulConsole console = new ZuulConsole(zuulLogId, zuulLogPath)) + { + console.LogExitCode((UInt32)e.NativeErrorCode); + } + throw; + } + finally + { + if (isConsole) + NativeMethods.FreeConsole(); + } + } + + return WaitProcess(stdoutRead, stdoutWrite, stderrRead, stderrWrite, stdinStream, stdin, pi.hProcess, + outputEncoding, zuulLogId, zuulLogPath, zuulOutputMaxBytes); + } + + internal static void CreateStdioPipes(NativeHelpers.STARTUPINFOEX si, out SafeFileHandle stdoutRead, + out SafeFileHandle stdoutWrite, out SafeFileHandle stderrRead, out SafeFileHandle stderrWrite, + out SafeFileHandle stdinRead, out SafeFileHandle stdinWrite) + { + NativeHelpers.SECURITY_ATTRIBUTES pipesec = new NativeHelpers.SECURITY_ATTRIBUTES(); + pipesec.bInheritHandle = true; + + if (!NativeMethods.CreatePipe(out stdoutRead, out stdoutWrite, pipesec, 0)) + throw new Win32Exception("STDOUT pipe setup failed"); + if (!NativeMethods.SetHandleInformation(stdoutRead, NativeHelpers.HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDOUT pipe handle setup failed"); + + if (!NativeMethods.CreatePipe(out stderrRead, out stderrWrite, pipesec, 0)) + throw new Win32Exception("STDERR pipe setup failed"); + if (!NativeMethods.SetHandleInformation(stderrRead, NativeHelpers.HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDERR pipe handle setup failed"); + + if (!NativeMethods.CreatePipe(out stdinRead, out stdinWrite, pipesec, 0)) + throw new Win32Exception("STDIN pipe setup failed"); + if (!NativeMethods.SetHandleInformation(stdinWrite, NativeHelpers.HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDIN pipe handle setup failed"); + + si.startupInfo.hStdOutput = stdoutWrite; + si.startupInfo.hStdError = stderrWrite; + si.startupInfo.hStdInput = stdinRead; + } + + internal static SafeMemoryBuffer CreateEnvironmentPointer(IDictionary environment) + { + IntPtr lpEnvironment = IntPtr.Zero; + if (environment != null && environment.Count > 0) + { + StringBuilder environmentString = new StringBuilder(); + foreach (DictionaryEntry kv in environment) + environmentString.AppendFormat("{0}={1}\0", kv.Key, kv.Value); + environmentString.Append('\0'); + + lpEnvironment = Marshal.StringToHGlobalUni(environmentString.ToString()); + } + return new SafeMemoryBuffer(lpEnvironment); + } + + internal static Result WaitProcess(SafeFileHandle stdoutRead, SafeFileHandle stdoutWrite, SafeFileHandle stderrRead, + SafeFileHandle stderrWrite, FileStream stdinStream, byte[] stdin, IntPtr hProcess, string outputEncoding, + string zuulLogId, string zuulLogPath, UInt32 zuulOutputMaxBytes) + { + // Default to using UTF-8 as the output encoding, this should be a sane default for most scenarios. + outputEncoding = String.IsNullOrEmpty(outputEncoding) ? "utf-8" : outputEncoding; + Encoding encodingInstance = Encoding.GetEncoding(outputEncoding); + + FileStream stdoutFS = new FileStream(stdoutRead, FileAccess.Read, 4096); + StreamReader stdout = new ZuulStreamReader(stdoutFS, encodingInstance, true, 4096); + stdoutWrite.Close(); + + FileStream stderrFS = new FileStream(stderrRead, FileAccess.Read, 4096); + StreamReader stderr = new ZuulStreamReader(stderrFS, encodingInstance, true, 4096); + stderrWrite.Close(); + + stdinStream.Write(stdin, 0, stdin.Length); + stdinStream.Close(); + + string stdoutStr, stderrStr = null; + + // Zuul: We add the hProcess argument here so that our + // follower can kill the process if it emits too much + // output. + GetProcessOutput(stdout, stderr, hProcess, zuulLogId, zuulLogPath, zuulOutputMaxBytes, out stdoutStr, out stderrStr); + UInt32 rc = GetProcessExitCode(hProcess); + + return new Result + { + StandardOut = stdoutStr, + StandardError = stderrStr, + ExitCode = rc + }; + } + + // Zuul: This method replaces the original + internal static void GetProcessOutput(StreamReader stdoutStream, StreamReader stderrStream, IntPtr hProcess, string zuulLogId, string zuulLogPath, UInt32 zuulOutputMaxBytes, out string stdout, out string stderr) + { + SafeWaitHandle process = new SafeWaitHandle(hProcess, true); + StreamFollower sf = new StreamFollower(process, stdoutStream, stderrStream, zuulLogId, zuulLogPath, zuulOutputMaxBytes); + sf.Follow(); + sf.Join(); + stdout = sf.outLogBytes.ToString(); + stderr = sf.errLogBytes.ToString(); + sf.LogExitCode(GetProcessExitCode(hProcess)); + sf.Dispose(); + } + + internal static void GetProcessOutput(StreamReader stdoutStream, StreamReader stderrStream, out string stdout, out string stderr) + { + var sowait = new EventWaitHandle(false, EventResetMode.ManualReset); + var sewait = new EventWaitHandle(false, EventResetMode.ManualReset); + string so = null, se = null; + ThreadPool.QueueUserWorkItem((s) => + { + so = stdoutStream.ReadToEnd(); + sowait.Set(); + }); + ThreadPool.QueueUserWorkItem((s) => + { + se = stderrStream.ReadToEnd(); + sewait.Set(); + }); + foreach (var wh in new WaitHandle[] { sowait, sewait }) + wh.WaitOne(); + stdout = so; + stderr = se; + } + + internal static UInt32 GetProcessExitCode(IntPtr processHandle) + { + SafeWaitHandle hProcess = new SafeWaitHandle(processHandle, true); + NativeMethods.WaitForSingleObject(hProcess, 0xFFFFFFFF); + + UInt32 exitCode; + if (!NativeMethods.GetExitCodeProcess(hProcess, out exitCode)) + throw new Win32Exception("GetExitCodeProcess() failed"); + return exitCode; + } + } +} diff --git a/zuul/ansible/base/library/module_utils/__init__.py b/zuul/ansible/base/library/module_utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/zuul/ansible/base/library/win_command.ps1 b/zuul/ansible/base/library/win_command.ps1 new file mode 100644 index 0000000000..3c4db69ec1 --- /dev/null +++ b/zuul/ansible/base/library/win_command.ps1 @@ -0,0 +1,155 @@ +#!powershell + +# This is based on: https://github.com/ansible-collections/ansible.windows/blob/c3f26bb4fa1b3a08ee2c045d2fdd53e1df171d19/plugins/modules/win_command.ps1 +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.Zuul.Win.Command.Process +#Requires -Module Ansible.ModuleUtils.FileUtil + +$spec = @{ + options = @{ + _raw_params = @{ type = "str" } + cmd = @{ type = 'str' } + argv = @{ type = "list"; elements = "str" } + chdir = @{ type = "path" } + creates = @{ type = "path" } + removes = @{ type = "path" } + stdin = @{ type = "str" } + output_encoding_override = @{ type = "str" } + zuul_log_id = @{ type = "str" } + zuul_output_max_bytes = @{ type = "int" } + } + required_one_of = @( + , @('_raw_params', 'argv', 'cmd') + ) + mutually_exclusive = @( + , @('_raw_params', 'argv', 'cmd') + ) + supports_check_mode = $false +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$chdir = $module.Params.chdir +$creates = $module.Params.creates +$removes = $module.Params.removes +$stdin = $module.Params.stdin +$output_encoding_override = $module.Params.output_encoding_override +$zuul_log_id = $module.Params.zuul_log_id +$zuul_output_max_bytes = $module.Params.zuul_output_max_bytes + +<# +There are 3 ways a command can be specified with win_command: + + 1. Through _raw_params - the value will be used as is + + - win_command: raw params here + + 2. Through cmd - the value will be used as is + + - win_command: + cmd: cmd to run here + + 3. Using argv - the values will be escaped using C argument rules + + - win_command: + argv: + - executable + - argument 1 + - argument 2 + - repeat as needed + +Each of these options are mutually exclusive and at least 1 needs to be specified. +#> +$filePath = $null +$rawCmdLine = if ($module.Params.cmd) { + $module.Params.cmd +} +elseif ($module.Params._raw_params) { + $module.Params._raw_params.Trim() +} +else { + $argv = $module.Params.argv + + # First resolve just the executable to an absolute path + $filePath = Resolve-ExecutablePath -FilePath $argv[0] -WorkingDirectory $chdir + + # Then combine the executable + remaining arguments and escape them + @( + ConvertTo-EscapedArgument -InputObject $filePath + $argv | Select-Object -Skip 1 | ConvertTo-EscapedArgument + ) -join " " +} + +$module.Result.cmd = $rawCmdLine +$module.Result.rc = 0 + +if ($creates -and $(Test-AnsiblePath -Path $creates)) { + $module.Result.msg = "skipped, since $creates exists" + $module.Result.skipped = $true + $module.ExitJson() +} + +if ($removes -and -not $(Test-AnsiblePath -Path $removes)) { + $module.Result.msg = "skipped, since $removes does not exist" + $module.Result.skipped = $true + $module.ExitJson() +} + +$commandParams = @{ + CommandLine = $rawCmdLine + ZuulLogId = $zuul_log_id + ZuulLogPath = "C:/ProgramData/Zuul/Zuul/console-$zuul_log_id.log" + ZuulOutputMaxBytes = $zuul_output_max_bytes +} +if ($filePath) { + $commandParams.FilePath = $filePath +} +if ($chdir) { + $commandParams.WorkingDirectory = $chdir +} +if ($stdin) { + $commandParams.InputObject = $stdin +} +if ($output_encoding_override) { + $commandParams.OutputEncodingOverride = $output_encoding_override +} + +$startDatetime = [DateTime]::UtcNow +try { + $cmdResult = Start-AnsibleWindowsProcess @commandParams +} +catch { + $module.Result.rc = 2 + + # Keep on checking inner exceptions to see if it has the NativeErrorCode to + # report back. + $exp = $_.Exception + while ($exp) { + if ($exp.PSObject.Properties.Name -contains 'NativeErrorCode') { + $module.Result.rc = $exp.NativeErrorCode + break + } + $exp = $exp.InnerException + } + + $module.FailJson("Failed to run: '$rawCmdLine': $($_.Exception.Message)", $_) +} + +$module.Result.cmd = $cmdResult.Command +$module.Result.changed = $true +$module.Result.stdout = $cmdResult.Stdout +$module.Result.stderr = $cmdResult.Stderr +$module.Result.rc = $cmdResult.ExitCode + +$endDatetime = [DateTime]::UtcNow +$module.Result.start = $startDatetime.ToString("yyyy-MM-dd HH:mm:ss.ffffff") +$module.Result.end = $endDatetime.ToString("yyyy-MM-dd HH:mm:ss.ffffff") +$module.Result.delta = $($endDatetime - $startDatetime).ToString("h\:mm\:ss\.ffffff") + +If ($module.Result.rc -ne 0) { + $module.FailJson("non-zero return code") +} + +$module.ExitJson() diff --git a/zuul/ansible/base/library/win_shell.ps1 b/zuul/ansible/base/library/win_shell.ps1 new file mode 100644 index 0000000000..ddd0573b7d --- /dev/null +++ b/zuul/ansible/base/library/win_shell.ps1 @@ -0,0 +1,152 @@ +#!powershell + +# This is based on: https://github.com/ansible-collections/ansible.windows/blob/2.5.0/plugins/modules/win_shell.ps1 +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil +#Requires -Module Ansible.ModuleUtils.FileUtil + +# TODO: add check mode support + +Set-StrictMode -Version 2 +$ErrorActionPreference = "Stop" + +# Cleanse CLIXML from stderr (sift out error stream data, discard others for now) +Function Format-Stderr($raw_stderr) { + Try { + # NB: this regex isn't perfect, but is decent at finding CLIXML amongst other stderr noise + If ($raw_stderr -match "(?s)(?.*)#< CLIXML(?.*)(?)(?.*)") { + $clixml = [xml]$matches["clixml"] + $filtered = $clixml.Objs.ChildNodes | + Where-Object { $_.Name -eq 'S' } | + Where-Object { $_.S -eq 'Error' } | + ForEach-Object { $_.'#text'.Replace('_x000D__x000A_', '') } | + Out-String + + $merged_stderr = "{0}{1}{2}{3}" -f @( + $matches["prenoise1"], + $matches["prenoise2"], + # filter out just the Error-tagged strings for now, and zap embedded CRLF chars + $filtered, + $matches["postnoise"]) | Out-String + + return $merged_stderr.Trim() + + # FUTURE: parse/return other streams + } + Else { + $raw_stderr + } + } + Catch { + "***EXCEPTION PARSING CLIXML: $_***" + $raw_stderr + } +} + +$params = Parse-Args $args -supports_check_mode $false + +$raw_command_line = Get-AnsibleParam -obj $params -name "_raw_params" -type "str" -failifempty $true +$chdir = Get-AnsibleParam -obj $params -name "chdir" -type "path" +$executable = Get-AnsibleParam -obj $params -name "executable" -type "path" +$creates = Get-AnsibleParam -obj $params -name "creates" -type "path" +$removes = Get-AnsibleParam -obj $params -name "removes" -type "path" +$stdin = Get-AnsibleParam -obj $params -name "stdin" -type "str" +$no_profile = Get-AnsibleParam -obj $params -name "no_profile" -type "bool" -default $false +$output_encoding_override = Get-AnsibleParam -obj $params -name "output_encoding_override" -type "str" +$zuul_log_id = Get-AnsibleParam -obj $params -name "zuul_log_id" -type "str" +$zuul_output_max_bytes = Get-AnsibleParam -obj $params -name "zuul_output_max_bytes" -type "int" + +$raw_command_line = $raw_command_line.Trim() + +$result = @{ + changed = $true + cmd = $raw_command_line +} + +if ($creates -and $(Test-AnsiblePath -Path $creates)) { + Exit-Json @{ msg = "skipped, since $creates exists"; cmd = $raw_command_line; changed = $false; skipped = $true; rc = 0 } +} + +if ($removes -and -not $(Test-AnsiblePath -Path $removes)) { + Exit-Json @{ msg = "skipped, since $removes does not exist"; cmd = $raw_command_line; changed = $false; skipped = $true; rc = 0 } +} + +$exec_args = $null +If (-not $executable -or $executable -eq "powershell") { + $exec_application = "powershell.exe" + + # force input encoding to preamble-free UTF8 so PS sub-processes (eg, Start-Job) don't blow up + $raw_command_line = "[Console]::InputEncoding = New-Object Text.UTF8Encoding `$false; " + $raw_command_line + + # Base64 encode the command so we don't have to worry about the various levels of escaping + $encoded_command = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($raw_command_line)) + + if ($stdin) { + $exec_args = "-encodedcommand $encoded_command" + } + else { + $exec_args = "-noninteractive -encodedcommand $encoded_command" + } + + if ($no_profile) { + $exec_args = "-noprofile $exec_args" + } +} +Else { + # FUTURE: support arg translation from executable (or executable_args?) to process arguments for arbitrary interpreter? + $exec_application = $executable + if (-not ($exec_application.EndsWith(".exe"))) { + $exec_application = "$($exec_application).exe" + } + $exec_args = "/c $raw_command_line" +} + +$command = "`"$exec_application`" $exec_args" +$run_command_arg = @{ + command = $command + zuul_log_id = $zuul_log_id + zuul_log_path = "C:/ProgramData/Zuul/Zuul/console-$zuul_log_id.log" + zuul_output_max_bytes = $zuul_output_max_bytes +} +if ($chdir) { + $run_command_arg['working_directory'] = $chdir +} +if ($stdin) { + $run_command_arg['stdin'] = $stdin +} +if ($output_encoding_override) { + $run_command_arg['output_encoding_override'] = $output_encoding_override +} + +$start_datetime = [DateTime]::UtcNow +try { + $command_result = Run-Command @run_command_arg +} +catch { + $result.changed = $false + try { + $result.rc = $_.Exception.NativeErrorCode + } + catch { + $result.rc = 2 + } + Fail-Json -obj $result -message $_.Exception.Message +} + +# TODO: decode CLIXML stderr output (and other streams?) +$result.stdout = $command_result.stdout +$result.stderr = Format-Stderr $command_result.stderr +$result.rc = $command_result.rc + +$end_datetime = [DateTime]::UtcNow +$result.start = $start_datetime.ToString("yyyy-MM-dd HH:mm:ss.ffffff") +$result.end = $end_datetime.ToString("yyyy-MM-dd HH:mm:ss.ffffff") +$result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") + +If ($result.rc -ne 0) { + Fail-Json -obj $result -message "non-zero return code" +} + +Exit-Json $result diff --git a/zuul/ansible/base/library/win_zuul_console.ps1 b/zuul/ansible/base/library/win_zuul_console.ps1 new file mode 100644 index 0000000000..8da29d6365 --- /dev/null +++ b/zuul/ansible/base/library/win_zuul_console.ps1 @@ -0,0 +1,69 @@ +#!powershell + +# Copyright (C) 2025 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 . + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType + +# We put the logs in this location to approximate the global /tmp on +# unix. We can't use the individual user's home directory because it +# may not be writeable if "become" is used. + +$LOG_STREAM_FILE = "C:/ProgramData/Zuul/Zuul/console-{0}.log" +$LOG_STREAM_PORT = 19886 + +$spec = @{ + options = @{ + _zuul_console_exec_path = @{ type = "str" } + path = @{ type = "str"; default = $LOG_STREAM_FILE } + port = @{ type = "int"; default = $LOG_STREAM_PORT } + state = @{ type = "str"; choices = "absent", "present" } + } +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +function Base64-Encode { + param ($Str) + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Str)) +} + +# The log path can have {} characters in it for python string +# interpolation; avoid shell issues by base64 encoding it. +$LogPath = Base64-Encode $module.Params.path + +if ($module.Params.state -eq "absent") { + try { + # Identify the process by port and kill it. + Get-Process -Id (Get-NetTCPConnection -LocalPort $module.Params.port).OwningProcess | Stop-Process + $module.Result.changed = $true + } catch {} +} else { + try { + # If we can find a process listening on this port, assume it + # is a pre-existing daemon and skip the rest. + Get-Process -Id (Get-NetTCPConnection -LocalPort $module.Params.port).OwningProcess | Out-Null + } catch { + $ConsoleCommand = "powershell -executionpolicy bypass -File $($module.Params._zuul_console_exec_path) $($LogPath) $($module.Params.port)" + # This method of starting a new process completely detaches it + # from the ssh process. + Invoke-WmiMethod -Path 'Win32_Process' -Name Create -ArgumentList $ConsoleCommand | Out-Null + $module.Result.changed = $true + # Return the resulting command for debugging. + $module.Result.cmd = $ConsoleCommand + } +} + +$module.ExitJson() diff --git a/zuul/ansible/win_zuul_console.cs b/zuul/ansible/win_zuul_console.cs new file mode 100644 index 0000000000..132420a706 --- /dev/null +++ b/zuul/ansible/win_zuul_console.cs @@ -0,0 +1,351 @@ +// Copyright (c) 2016 IBM Corp. +// Copyright (C) 2025 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 . + +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; + +// This file follows the structure of win_console.py closely (meaning +// some of this code is more pythonic than C#-like). + +internal class ZuulConsole : IDisposable +{ + public string path; + public FileStream file; + public long size; + + public ZuulConsole(string path) + { + this.path = path; + this.file = new FileStream( + path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + this.size = new FileInfo(path).Length; + } + + public void Dispose() + { + file.Dispose(); + } +} + +public class Server +{ + private const int MAX_REQUEST_LEN = 1024; + private const int REQUEST_TIMEOUT = 10; + // This is the version we report to the zuul_stream callback. It is + // expected that this (zuul_console) process can be long-lived, so if + // there are updates this ensures a later streaming callback can still + // talk to us. + private const int ZUUL_CONSOLE_PROTO_VERSION = 1; + + private string path; + private Socket socket; + + public Server(string path, int port) + { + this.path = path; + socket = new Socket( + AddressFamily.InterNetworkV6, + SocketType.Stream, + ProtocolType.Tcp); + socket.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, true); + socket.SetSocketOption( + SocketOptionLevel.IPv6, + SocketOptionName.IPv6Only, false); + socket.Bind(new IPEndPoint(IPAddress.Any, port)); + socket.Listen(1); + } + + private Socket Accept() + { + return socket.Accept(); + } + + public void Run() + { + while (true) + { + Socket conn = Accept(); + Thread t = new Thread(StartHandleOneConnection); + t.IsBackground = true; + t.Start((object) conn); + } + } + + private void StartHandleOneConnection(object conn) + { + try + { + HandleOneConnection((Socket) conn); + } + catch (Exception e) + { + Console.WriteLine("Error in connection handler: {0}", + e.ToString()); + } + } + + private ZuulConsole ChunkConsole(Socket conn, string logUuid) + { + ZuulConsole console; + try + { + console = new ZuulConsole(string.Format(path, logUuid)); + } + catch + { + return null; + } + while (true) + { + Byte[] chunk = new Byte[4096]; + int len = console.file.Read(chunk, 0, 4096); + if (len == 0) + { + break; + } + conn.Send(chunk, len, SocketFlags.None); + } + return console; + } + + private bool FollowConsole(ZuulConsole console, Socket conn) + { + while (true) + { + // As long as we have unread data, keep reading/sending + while (true) + { + Byte[] chunk = new Byte[4096]; + int len = console.file.Read(chunk, 0, 4096); + if (len > 0) + { + conn.Send(chunk, len, SocketFlags.None); + } + else + { + break; + } + } + // At this point, we are waiting for more data to be written + Thread.Sleep(500); + + // Check to see if the remote end has sent any data, + // if so, discard + if (conn.Poll(0, SelectMode.SelectError)) + { + return false; + } + if (conn.Poll(0, SelectMode.SelectRead)) + { + Byte[] chunk = new Byte[4096]; + int ret = conn.Receive(chunk); + // Discard anything read, if input is eof, it has + // disconnected. + if (ret == 0) + return false; + } + + // See if the file has been truncated + try + { + long currentSize = new FileInfo(console.path).Length; + if (currentSize < console.size) + { + return true; + } + console.size = currentSize; + } catch { + return true; + } + } + } + private string GetCommand(Socket conn) + { + Byte[] buff = new Byte[MAX_REQUEST_LEN]; + DateTime start = DateTime.UtcNow; + int pos = 0; + + while (true) + { + int elapsed = (DateTime.UtcNow - start).Seconds; + int timeout = Math.Max(REQUEST_TIMEOUT - elapsed, 0); + if (timeout == 0) + { + throw new Exception("Timeout while waiting for input"); + } + + if (conn.Poll(0, SelectMode.SelectRead)) + { + int len = conn.Receive(buff, pos, MAX_REQUEST_LEN - pos, + SocketFlags.None); + if (len == 0) { + throw new Exception("Remote side closed connection"); + } + } + if (conn.Poll(0, SelectMode.SelectError)) + { + throw new Exception("Received error event"); + } + + if (pos >= MAX_REQUEST_LEN) + { + throw new Exception("Request too long"); + } + + try + { + string ret = System.Text.Encoding.UTF8.GetString(buff); + int x = ret.IndexOf('\n'); + if (x > 0) + { + return ret.Substring(0,x).Trim(); + } + } + catch + { + } + } + } + private string CleanUuid(string logUuid) + { + // Make use the input isn't trying to be clever and + // construct some path like /tmp/console-/../../something + return Path.GetFileName(logUuid); + } + + private void HandleOneConnection(Socket conn) + { + // V1 protocol + // ----------- + // v: get version number, is remote version + // s: send logs for + // f: finalise/cleanup + string logUuid; + + while (true) + { + string command = GetCommand(conn); + if (command.StartsWith("v:")) + { + // NOTE(ianw) : remote sends its version. We currently + // don't have anything to do with this value, so ignore + // for now. + conn.Send( + System.Text.Encoding.UTF8.GetBytes( + String.Format("{0}\n", ZUUL_CONSOLE_PROTO_VERSION))); + continue; + } + else if (command.StartsWith("f:")) + { + logUuid = CleanUuid(command.Substring(2)); + try + { + File.Delete(string.Format(path, logUuid)); + } + catch + { + } + continue; + } + else if (command.StartsWith("s:")) + { + logUuid = CleanUuid(command.Substring(2)); + break; + } + else + { + // NOTE(ianw): 2022-07-21 In releases < 6.3.0 the streaming + // side would just send a raw uuid and nothing else; so by + // default assume that is what is coming in here. We can + // remove this fallback when we decide it is no longer + // necessary. + logUuid = CleanUuid(command); + break; + } + } + + // FIXME: this won't notice disconnects until it tries to send + ZuulConsole console = null; + try + { + while (true) + { + if (console != null) + { + try + { + console.Dispose(); + } + catch + { + } + } + while (true) + { + console = ChunkConsole(conn, logUuid); + if (console != null) + { + break; + } + conn.Send(System.Text.Encoding.UTF8.GetBytes( + "[Zuul] Log not found\n")); + Thread.Sleep(500); + } + while (true) + { + if (FollowConsole(console, conn)) + { + break; + } + else + { + return; + } + } + } + } finally { + conn.Close(); + if (console != null) + { + console.Dispose(); + } + } + } +} + +public class WinZuulConsole +{ + public static void Main(string[] args) + { + if (args.Length < 2) + { + throw new Exception("Not enough arguments"); + } + + string path = System.Text.Encoding.UTF8.GetString( + Convert.FromBase64String(args[0])); + int port = Int32.Parse(args[1]); + + Server s = new Server(path, port); + s.Run(); + } +} diff --git a/zuul/executor/server.py b/zuul/executor/server.py index 2436741071..c3c3d3a988 100644 --- a/zuul/executor/server.py +++ b/zuul/executor/server.py @@ -406,7 +406,8 @@ class KubeFwd(object): def __init__(self, zuul_event_id, build, kubeconfig, context, namespace, pod): - self.port = None + self.port1 = None + self.port2 = None self.fwd = None self.log = get_annotated_logger( logging.getLogger("zuul.ExecutorServer"), @@ -415,22 +416,27 @@ class KubeFwd(object): self.context = context self.namespace = namespace self.pod = pod - self.socket = None + self.socket1 = None + self.socket2 = None def _getSocket(self): - # Reserve a port so that we can restart the forwarder if it - # exits, which it will if there is any connection problem at - # all. - self.socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.socket.bind(('::', 0)) - self.port = self.socket.getsockname()[1] + # Reserve a port for each of the possible log streaming ports + # so that we can restart the forwarder if it exits, which it + # will if there is any connection problem at all. + self.socket1 = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + self.socket1.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket1.bind(('::', 0)) + self.port1 = self.socket1.getsockname()[1] + self.socket2 = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + self.socket2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket2.bind(('::', 0)) + self.port2 = self.socket2.getsockname()[1] def start(self): if self.fwd: return - if self.socket is None: + if self.socket1 is None or self.socket2 is None: self._getSocket() cmd = [ @@ -442,7 +448,8 @@ class KubeFwd(object): shlex.quote(self.namespace), 'port-forward', shlex.quote('pod/%s' % self.pod), - '%s:19885' % self.port, + '%s:19885' % self.port1, + '%s:19886' % self.port2, ';', 'do', ':;', 'done', ] cmd = ' '.join(cmd) @@ -455,19 +462,21 @@ class KubeFwd(object): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=devnull) + # This is a quick check to make sure it started correctly, so + # we only check the first line and the first port. line = fwd.stdout.readline().decode('utf8') m = re.match(r'^Forwarding from 127.0.0.1:(\d+) -> 19885', line) port = None if m: port = m.group(1) - if port != str(self.port): + if port != str(self.port1): self.log.error("Could not find the forwarded port: %s", line) self.stop() raise Exception("Unable to start kubectl port forward") self.fwd = fwd pgid = os.getpgid(self.fwd.pid) - self.log.info('Started Kubectl port forward on port %s with ' - 'process group %s', self.port, pgid) + self.log.info('Started Kubectl port forward on ports %s and %s with ' + 'process group %s', self.port1, self.port2, pgid) def stop(self): try: @@ -488,9 +497,15 @@ class KubeFwd(object): except Exception: self.log.exception('Unable to stop kubectl port-forward:') try: - if self.socket: - self.socket.close() - self.socket = None + if self.socket1: + self.socket1.close() + self.socket1 = None + except Exception: + self.log.exception('Unable to close port-forward socket:') + try: + if self.socket2: + self.socket2.close() + self.socket2 = None except Exception: self.log.exception('Unable to close port-forward socket:') @@ -1112,6 +1127,7 @@ class AnsibleJob(object): plugin_dir = self.executor_server.ansible_manager.getAnsiblePluginDir( self.ansible_version) self.library_dir = os.path.join(plugin_dir, 'library') + self.module_utils_dir = os.path.join(self.library_dir, 'module_utils') self.action_dir = os.path.join(plugin_dir, 'action') self.callback_dir = os.path.join(plugin_dir, 'callback') self.lookup_dir = os.path.join(plugin_dir, 'lookup') @@ -2822,8 +2838,10 @@ class AnsibleJob(object): try: fwd.start() self.port_forwards.append(fwd) - zuul_resources[node.name[0]]['stream_port'] = \ - fwd.port + zuul_resources[node.name[0]]['stream_port1'] = \ + fwd.port1 + zuul_resources[node.name[0]]['stream_port2'] = \ + fwd.port2 except Exception: self.log.exception("Unable to start port forward:") self.log.error("Kubectl and socat are required for " @@ -3067,8 +3085,8 @@ class AnsibleJob(object): config.write('fact_caching = jsonfile\n') config.write('fact_caching_connection = %s\n' % self.jobdir.fact_cache) - config.write('library = %s\n' - % self.library_dir) + config.write('library = %s\n' % self.library_dir) + config.write('module_utils = %s\n' % self.module_utils_dir) config.write('command_warnings = False\n') # Disable the Zuul callback plugins for the freeze playbooks # as that output is verbose and would be confusing for users.