Add Windows live log streaming

This adds support for live log streaming on Windows hosts.

The daemon is written in C#, but is structured exactly like the
Python daemon for Posix, so that any changes to one can be easily
translated to the other.

Like the Python/Posix version, it is necessary to vendor some
Powershell and C# code from Ansible and the ansible.windows
collection.  Minimal changes were made to these (in fact, much
more minimal than the changes to the command.py module for the
Posix version).  All changes involve the word "Zuul" for easy
identification.  Most of the work to write out the console
log files is in a new file, Ansible.Zuul.Win.Common.cs which
appears only in Zuul.

Becasue it's possible to run the Posix log streaming daemon
in WSL, and it's also possible to run both "win_command" and
"command" tasks on the same host, outside and inside of WSL,
the Windows log streaming daemon (win_zuul_console) runs on
a different port, so that both may be running on the same host
at the same time.  This is more straightforward than trying
to have both daemons be aware of the file paths of the other.

I did make an attempt to create a test environment with wine,
dotnet, and powershell all running under linux that we could use
in the gate, however something about how Ansible links the C#
modules is different enough to render that nonfunctional.
Perhaps someday it will work; until then, we will have to use our
best efforts to maintain this and manually test with a Windows
VM.  The procedure for running the remote tests is the same for
Windows as it is for Posix.

A copy of the "command" remote streaming test is added for windows,
but disabled in the gate due to test resources.

Change-Id: I35c220c1774b1943165075c3730236c78b95b18d
This commit is contained in:
James E. Blair 2025-03-15 14:37:54 -07:00
parent dd065c17a7
commit 63e3552e9c
50 changed files with 3625 additions and 72 deletions

View File

@ -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-<uuid>-<task_id>-<host>.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-<uuid>-<task_id>-<host>.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
----------

View File

@ -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.

View File

@ -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:

View File

@ -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

View File

@ -0,0 +1,2 @@
- name: Echo message
win_command: "cmd /c echo {{item}}"

View File

@ -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

View File

@ -0,0 +1,3 @@
- name: Include echo role
include_role:
name: win-echo-role

View File

@ -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

View File

@ -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
# (<ansible.plugins.callback.zuul_stream.CallbackModule object at'
# 0x7f89f72a20b0>): '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'

View File

@ -0,0 +1 @@
../../base/action/win_command.py

View File

@ -0,0 +1 @@
../../base/action/win_shell.py

View File

@ -0,0 +1 @@
../../base/action/win_zuul_console.py

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.Zuul.Win.Command.Process.cs

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.Zuul.Win.Common.cs

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs

View File

@ -0,0 +1 @@
../../../base/library/module_utils/__init__.py

View File

@ -0,0 +1 @@
../../base/library/win_command.ps1

View File

@ -0,0 +1 @@
../../base/library/win_shell.ps1

View File

@ -0,0 +1 @@
../../base/library/win_zuul_console.ps1

View File

@ -0,0 +1 @@
../win_zuul_console.cs

View File

@ -0,0 +1 @@
../../base/action/win_command.py

View File

@ -0,0 +1 @@
../../base/action/win_shell.py

View File

@ -0,0 +1 @@
../../base/action/win_zuul_console.py

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Command.Process.psm1

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.ModuleUtils.Zuul.Win.Shell.CommandUtil.psm1

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.Zuul.Win.Command.Process.cs

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.Zuul.Win.Common.cs

View File

@ -0,0 +1 @@
../../../base/library/module_utils/Ansible.Zuul.Win.Shell.Process.cs

View File

@ -0,0 +1 @@
../../../base/library/module_utils/__init__.py

View File

@ -0,0 +1 @@
../../base/library/win_command.ps1

View File

@ -0,0 +1 @@
../../base/library/win_shell.ps1

View File

@ -0,0 +1 @@
../../base/library/win_zuul_console.ps1

View File

@ -0,0 +1 @@
../win_zuul_console.cs

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
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

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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<SafeHandle> values = new List<SafeHandle>();
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
{
/// <summary>
/// Parses a command line string into an argv array according to the Windows rules
/// </summary>
/// <param name="lpCommandLine">The command line to parse</param>
/// <returns>An array of arguments interpreted by Windows</returns>
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();
}
}
/// <summary>
/// Creates a process based on the CreateProcess API call and wait for it to complete.
/// </summary>
/// <param name="lpApplicationName">The name of the executable or batch file to execute</param>
/// <param name="lpCommandLine">The command line to execute, typically this includes lpApplication as the first argument</param>
/// <param name="lpCurrentDirectory">The full path to the current directory for the process, null will have the same cwd as the calling process</param>
/// <param name="environment">A dictionary of key/value pairs to define the new process environment</param>
/// <param name="stdin">A byte array to send over the stdin pipe</param>
/// <param name="outputEncoding">The character encoding for decoding stdout/stderr output of the process.</param>
/// <param name="waitChildren">Whether to wait for any children spawned by the process to finished (Server2012 +).</param>
/// <returns>Result object that contains the command output and return code</returns>
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);
}
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="applicationName">The name of the executable or batch file to execute</param>
/// <param name="commandLine">The command line to execute, typically this includes applicationName as the first argument</param>
/// <param name="processAttributes">SecurityAttributes to assign to the new process, set to null to use the defaults</param>
/// <param name="threadAttributes">SecurityAttributes to assign to the new thread, set to null to use the defaults</param>
/// <param name="inheritHandles">Any inheritable handles in the calling process is inherited in the new process</param>
/// <param name="creationFlags">Custom creation flags to use when creating the new process</param>
/// <param name="environment">A dictionary of key/value pairs to define the new process environment</param>
/// <param name="currentDirectory">The full path to the current directory for the process, null will have the same cwd as the calling process</param>
/// <param name="startupInfo">Custom StartupInformation to use when creating the new process</param>
/// <returns>ProcessInformation containing a handle to the process and main thread as well as the pid/tid.</returns>
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,
};
}
/// <summary>
/// Resume a suspended thread.
/// </summary>
/// <param name="thread">The thread handle to resume</param>
public static void ResumeThread(SafeHandle thread)
{
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
throw new Win32Exception("ResumeThread() failed");
}
/// <summary>
/// Gets the exit code for the specified process handle.
/// </summary>
/// <param name="processHandle">The process handle to get the exit code for.</param>
/// <returns>The process exit code.</returns>
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);
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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("(?<clixml><Objs.+</Objs>)(?<post>.*)");
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);
}
}
}

View File

@ -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
{
/// <summary>
/// Parses a command line string into an argv array according to the Windows rules
/// </summary>
/// <param name="lpCommandLine">The command line to parse</param>
/// <returns>An array of arguments interpreted by Windows</returns>
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();
}
}
/// <summary>
/// Searches the path for the executable specified. Will throw a Win32Exception if the file is not found.
/// </summary>
/// <param name="lpFileName">The executable to search for</param>
/// <returns>The full path of the executable to search for</returns>
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);
}
*/
/// <summary>
/// Creates a process based on the CreateProcess API call.
/// </summary>
/// <param name="lpApplicationName">The name of the executable or batch file to execute</param>
/// <param name="lpCommandLine">The command line to execute, typically this includes lpApplication as the first argument</param>
/// <param name="lpCurrentDirectory">The full path to the current directory for the process, null will have the same cwd as the calling process</param>
/// <param name="environment">A dictionary of key/value pairs to define the new process environment</param>
/// <param name="stdin">A byte array to send over the stdin pipe</param>
/// <param name="outputEncoding">The character encoding for decoding stdout/stderr output of the process.</param>
/// <returns>Result object that contains the command output and return code</returns>
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;
}
}
}

View File

@ -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()

View File

@ -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)(?<prenoise1>.*)#< CLIXML(?<prenoise2>.*)(?<clixml><Objs.+</Objs>)(?<postnoise>.*)") {
$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

View File

@ -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 <http://www.gnu.org/licenses/>.
#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()

View File

@ -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 <http://www.gnu.org/licenses/>.
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:<ver> get version number, <ver> is remote version
// s:<uuid> send logs for <uuid>
// f:<uuid> finalise/cleanup <uuid>
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();
}
}

View File

@ -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.