commit a1a4b9ccebaa9c9635edaa8449975f0e064cc222
parent b661fa39ccb4e112ab9cb0c490e6f08f6af97995
Author: Asher Morgan <asher@ashermorgan.net>
Date: Sun, 4 Oct 2026 16:32:42 -0700
Add basic Windows support
Largely based off of the Windows-specific code in the official ssh and winrm
connection plugins.
Diffstat:
5 files changed, 50 insertions(+), 13 deletions(-)
diff --git a/aoh/client.py b/aoh/client.py
@@ -272,9 +272,10 @@ def create_runner(playbook, args):
color |= os.getenv('ANSIBLE_FORCE_COLOR', '').lower() in TRUTHY_VALUES
req = {
- 'host': platform.node(),
'args': args,
'color': color,
+ 'host': platform.node(),
+ 'is_windows': sys.platform == 'win32',
}
status, headers, res = send_json_request(url, req, 'POST')
diff --git a/aoh/connection_plugins/aoh.py b/aoh/connection_plugins/aoh.py
@@ -9,8 +9,6 @@ DOCUMENTATION = """
required: true
env:
- name: ANSIBLE_AOH_DIR
- vars:
- - name: ansible_aoh_dir
aoh_timeout:
description: The timeout for AoH responses, in seconds
type: integer
@@ -32,6 +30,12 @@ DOCUMENTATION = """
- name: ANSIBLE_AOH_KEEP_ALIVE_COUNT
vars:
- name: ansible_aoh_keep_alive_count
+ aoh_is_windows:
+ description: Whether the client host runs Windows
+ type: bool
+ default: false
+ env:
+ - name: ANSIBLE_AOH_IS_WINDOWS
"""
import base64
@@ -42,12 +46,15 @@ import select
import typing as t
from uuid import uuid4
+from ansible._internal._powershell import _clixml
from ansible.errors import (
AnsibleConnectionFailure,
AnsibleError,
AnsibleFileNotFound,
)
+from ansible.plugins import AnsiblePlugin
from ansible.plugins.connection import ConnectionBase
+from ansible.plugins.shell.cmd import ShellModule as CmdShellModule
from ansible.utils.display import Display
display = Display()
@@ -60,10 +67,27 @@ class Connection(ConnectionBase):
has_pipelining = True
has_tty = False
+ _shell: CmdShellModule
def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
+ AnsiblePlugin.__init__(self) # enable get_option()
+ self.is_windows = self.get_option('aoh_is_windows')
+
+ if self.is_windows:
+ self._shell_type = 'cmd' # must be set before super().__init__()
+
super().__init__(*args, **kwargs)
+ if self.is_windows and self._shell.SHELL_FAMILY != 'cmd':
+ display.warning(
+ msg='The aoh connection plugin should have the shell type of ' \
+ f'cmd and not {self._shell.SHELL_FAMILY}. This may ' \
+ 'result in an error when attempting to run more complex ' \
+ 'commands.',
+ help_text='Unset ansible_shell_type or set to cmd to use the ' \
+ 'required cmd shell.',
+ )
+
self.timeout = self.get_option('aoh_timeout')
self.keep_alive_count = self.get_option('aoh_keep_alive_count')
self.keep_alive_interval = round(self.timeout/self.keep_alive_count, 1)
@@ -73,6 +97,11 @@ class Connection(ConnectionBase):
self._recvpoll = None
self._connected = False
+ if self.is_windows:
+ # ty: ignore[invalid-assignment]
+ self.module_implementation_preferences = ('.ps1', '.exe', '')
+ self.allow_executable = False # Prevent use of "/bin/sh -c ..."
+
def _connect(self) -> Connection: # noqa: F821
"""Connect to the host."""
@@ -167,6 +196,9 @@ class Connection(ConnectionBase):
display.vvv(f'EXEC {cmd}', host=self._play_context.remote_addr)
+ if self.is_windows:
+ sudoable = False
+
msg = {
'cmd': cmd,
'stdin': in_data and in_data.decode('latin1'),
@@ -182,11 +214,14 @@ class Connection(ConnectionBase):
res = self._send_message_sync({'exec': msg})
if not all(k in res for k in ['returncode', 'stdout', 'stderr']):
raise AnsibleError('Received invalid AoH EXEC response')
- return (
- res['returncode'],
- res['stdout'].encode('latin1'),
- res['stderr'].encode('latin1'),
- )
+
+ stdout = res['stdout'].encode('latin1')
+ stderr = res['stderr'].encode('latin1')
+
+ if self.is_windows:
+ stderr = _clixml.replace_stderr_clixml(stderr)
+
+ return (res['returncode'], stdout, stderr)
def put_file(self, in_path: str, out_path: str) -> None:
diff --git a/aoh/runner.py b/aoh/runner.py
@@ -60,13 +60,14 @@ def get_config_values(playbook, keys):
class Runner:
"""An Ansible-over-HTTP playbook runner."""
- def __init__(self, playbook, host, args, color, passwords):
+ def __init__(self, playbook, host, args, is_windows, color, passwords):
"""Create a new AoH runner."""
self.id = str(uuid.uuid4()) # Runner ID (set to None after teardown)
self.playbook = playbook
self.host = self.playbook.host or host or 'aoh_node'
self.args = args
+ self.is_windows = is_windows
self.color = color
self.passwords = passwords
@@ -122,6 +123,7 @@ class Runner:
for key, val in self.playbook.env.items():
env[key] = val
env['ANSIBLE_AOH_DIR'] = self._DIR
+ env['ANSIBLE_AOH_IS_WINDOWS'] = '1' if self.is_windows else '0'
env['ANSIBLE_CONNECTION_PLUGINS'] = ':'.join(
[_CONNECTION_PLUGIN_DIR] +
config['DEFAULT_CONNECTION_PLUGIN_PATH']
diff --git a/aoh/server.py b/aoh/server.py
@@ -102,7 +102,7 @@ def new_runner(path):
return {'err': 'Bad or banned arguments passed'}, 400
runner = Runner(playbook, request.json['host'], request.json['args'],
- request.json['color'], act_pws)
+ request.json['is_windows'], request.json['color'], act_pws)
assert runner.id is not None
_RUNNERS[runner.id] = runner
diff --git a/tests/e2e/test_connection.yml b/tests/e2e/test_connection.yml
@@ -35,11 +35,10 @@
- name: raw with unicode arg and output
raw: "{{ echo_commands[action_prefix ~ ansible_connection ~ '_' ~ (ansible_shell_type|default(''))] | default(echo_commands['default']) }}"
vars:
- # Windows over SSH does not have a way to set the console codepage to allow UTF-8. We need to
+ # Windows over AoH does not have a way to set the console codepage to allow UTF-8. We need to
# wrap the commands we send to the remote host to get it working.
echo_commands:
- win_ssh_powershell: '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; echo {{ echo_string }}'
- win_ssh_cmd: 'powershell.exe -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; echo {{ echo_string }}"'
+ win_aoh_cmd: 'powershell.exe -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; echo {{ echo_string }}"'
default: echo {{ echo_string }}
register: command
- name: check output of raw with unicode arg and output