commit e875f4c57607fe14d456256c6e461e8cdcbaaa9e
parent 89ff70b15158c704dbf44d9b0ee42031659ede99
Author: Asher Morgan <asher@ashermorgan.net>
Date: Sat, 24 Oct 2026 21:44:12 -0700
Add e2e tests for Windows clients
Diffstat:
10 files changed, 318 insertions(+), 176 deletions(-)
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
@@ -34,7 +34,7 @@ jobs:
run: python -m unittest tests.unit
- run-e2e-tests:
+ run-e2e-tests-linux:
runs-on: ubuntu-latest
steps:
@@ -44,6 +44,47 @@ jobs:
run: ./tests/e2e/run.sh
+ run-e2e-tests-windows:
+ runs-on: windows-2025
+
+ env:
+ DOCKER_HOST: tcp://127.0.0.1:2375
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v6
+
+ - name: Replace symlinks with copies
+ run: |
+ Remove-Item -LiteralPath .\aoh\templates\client.py -Force
+ Copy-Item -LiteralPath .\aoh\client.py -Destination .\aoh\templates\client.py
+
+ - name: Install Alpine Linux in WSL
+ run: |
+ wsl --install --no-distribution
+ $url = "https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/x86_64/alpine-minirootfs-3.24.2-x86_64.tar.gz"
+ Invoke-WebRequest -Uri $url -OutFile alpine.tar.gz
+ wsl --install --name Alpine --from-file alpine.tar.gz
+
+ - name: Install Docker in WSL
+ run: |
+ wsl -d Alpine -- apk update
+ wsl -d Alpine -- apk add docker
+ wsl -d Alpine -- sh -c 'mkdir -p /etc/docker && echo "{\"hosts\": [\"tcp://127.0.0.1:2375\", \"unix:///var/run/docker.sock\"]}" > /etc/docker/daemon.json'
+
+ - name: Start dockerd in WSL
+ run: |
+ wsl -d Alpine -- sh -c 'nohup sleep infinity > /dev/null 2>&1 &'
+ wsl -d Alpine -- sh -c 'nohup dockerd > /var/log/dockerd.log 2>&1 & sleep 15 && docker info'
+
+ - name: Install Docker and Python
+ run: choco install docker-cli python3 -y
+
+ - name: Run end-to-end tests
+ shell: powershell
+ run: ./tests/e2e/run.ps1
+
+
build-and-push-image:
runs-on: ubuntu-latest
@@ -51,7 +92,8 @@ jobs:
needs:
- run-unit-tests
- - run-e2e-tests
+ - run-e2e-tests-linux
+ - run-e2e-tests-windows
permissions:
contents: read
diff --git a/README.md b/README.md
@@ -84,14 +84,12 @@ Options:
<opts> Any (server-approved) ansible-playbook(1) options
```
-<!--
-Windows clients are also supported. Just use the `Invoke-RestMethod` cmdlet
-instead of `curl`.
+Windows clients are also supported. Just use the `Invoke-RestMethod` PowerShell
+cmdlet instead of `curl`.
```
-PS> irm http://<server IP>:8000 | python
+PS> irm http://<server IP>:8000 | python3
```
--->
Finally, stop the AoH server when you're done using it.
@@ -288,7 +286,6 @@ AoH:
variable must not be overridden by, for example, variables in `host_vars/*`
files. Refer to Ansible's [variable precedence
documentation][ansible-precedence] for more details.
-- Support for Windows clients is still experimental.
[ansible-precedence]: https://docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_variables.html#understanding-variable-precedence
diff --git a/aoh/client.py b/aoh/client.py
@@ -301,6 +301,9 @@ def create_runner(playbook, args):
def cli(args):
"""Execute the AoH client CLI."""
+ if sys.platform == 'win32':
+ sys.stdout.reconfigure(encoding='utf-8')
+
if '-h' in args or '--help' in args:
if len(args) >= 1 and args[0] != '-':
prog = args[0]
diff --git a/tests/e2e/playbooks.yml b/tests/e2e/playbooks.yml
@@ -1,20 +1,25 @@
+# Run Ansible tests for connection plugins, with and without pipelining
test1.yml:
path: test_connection.yml
-
test2.yml:
path: test_connection.yml
env:
ANSIBLE_PIPELINING: 'true'
+# Run Ansible tests for local become, with and without pipelining
test3.yml:
- path: test_become_password_handling.yml
-
+ path: test_become_unix.yml
test4.yml:
- path: test_become_password_handling.yml
+ path: test_become_unix.yml
env:
ANSIBLE_PIPELINING: 'true'
+# Test basic runas become functionality on Windows
test5.yml:
+ path: test_become_windows.yml
+
+# A long-running playbook that is used to test connection failures
+test6.yml:
path: test_connection_failure.yml
env:
ANSIBLE_TIMEOUT: '1'
diff --git a/tests/e2e/run.ps1 b/tests/e2e/run.ps1
@@ -0,0 +1,69 @@
+# Note: This scrupt requires the docker daemon to be running from within WSL
+
+$ErrorActionPreference = 'Stop'
+
+Push-Location $PSScriptRoot
+
+function _Docker {
+ param([Parameter(ValueFromRemainingArguments = $true)][string[]] $DockerArguments)
+
+ & docker @DockerArguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "Docker command failed with exit code $LASTEXITCODE"
+ }
+}
+
+_Docker build -t aoh-test-server ../../
+try {
+ _Docker network create aoh-test
+} catch {
+ # Ignore failure if network exists
+}
+
+try {
+ for ($i = 1; $i -le 6; $i++) {
+ if ($i -eq 6) {
+ # Test 6 is different, and we only run it from run.sh
+ Write-Output '================================'
+ Write-Output "Skipping test$i.yml on Windows runner..."
+ Write-Output '================================'
+ continue
+ }
+
+ Write-Output '================================'
+ Write-Output "Running test$i.yml..."
+ Write-Output '================================'
+
+ _Docker run --rm --network host --detach -h aoh-test-server `
+ --name aoh-test-server aoh-test-server
+ _Docker cp "$PSScriptRoot/." 'aoh-test-server:/aoh/'
+ Start-Sleep -Seconds 1
+ irm http://127.0.0.1:8000/run | python3 - test$i.yml
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Output '================================'
+ Write-Output "test$i.yml failed."
+ Write-Output '================================'
+ throw "test$i.yml failed."
+ } else {
+ Write-Output '================================'
+ Write-Output "test$i.yml passed."
+ Write-Output '================================'
+ Write-Output ''
+ }
+
+ _Docker stop aoh-test-server
+ Start-Sleep -Seconds 1
+ }
+} finally {
+ try {
+ _Docker stop aoh-test-server
+ } catch {
+ # Ignore failures if server already stopped
+ }
+}
+
+Write-Output '================================'
+Write-Output 'All tests passed.'
+Write-Output '================================'
+exit 0
diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh
@@ -23,13 +23,13 @@ if [ $($DOCKER network ls --filter name=aoh-test | wc -l) -eq 1 ]; then
$DOCKER network create aoh-test
fi
-for i in {1..5}; do
+for i in {1..6}; do
echo ================================
echo Running test$i.yml...
echo ================================
- if [ "$i" -eq 5 ]; then
- # Test 5 is different: start a long-running playbook, kill the client,
+ if [ "$i" -eq 6 ]; then
+ # Test 6 is different: start a long-running playbook, kill the client,
# and then check to make sure that server cleans everything up
$DOCKER run --rm --network aoh-test --detach -h aoh-test-server \
--name aoh-test-server -v ./:/aoh \
diff --git a/tests/e2e/test_become_password_handling.yml b/tests/e2e/test_become_password_handling.yml
@@ -1,158 +0,0 @@
-# Adapted from Ansible's test/integration/targets/connection_local/test_become_password_handling.yml
-
-- hosts: aoh
- tasks:
- - name: skip this test for non-root users
- block:
- - debug:
- msg: Skipping sudo test for non-root user.
- - meta: end_play
- when: ansible_facts.user_id != 'root'
-
- - name: attempt root-to-root sudo become (no-op, should succeed)
- vars:
- ansible_become: yes
- ansible_become_user: root
- ansible_become_method: sudo
- raw: whoami
- register: root_noop_sudo
-
- - assert:
- that:
- - root_noop_sudo.stdout is contains "root"
-
- - name: create an unprivileged become user pair
- include_role:
- name: setup_become_user_pair
- public: true
-
- - name: deploy sudo shim
- copy:
- src: sudoshim.sh
- dest: /tmp/sudoshim.sh
- mode: u+x
-
- - set_fact:
- default_sudo_flags: "{{ lookup('config', 'become_flags', plugin_type='become', plugin_name='sudo') }}"
-
- - name: apply shared become vars to all tasks that use the sudo test shim
- vars:
- ansible_become: yes
- ansible_become_method: sudo
- ansible_become_user: '{{ target_user_name }}'
- ansible_become_password: '{{ target_user_password }}'
- ansible_become_exe: /tmp/sudoshim.sh
- intermediate: "{{ ((' --intermediate-user ' + intermediate_user_name) if intermediate_user_name is defined else '') }}"
- block:
- - name: verify stdout/stderr noise is present
- raw: echo $(whoami) ran
- vars:
- ansible_become_flags: "{{ default_sudo_flags ~ intermediate ~ ' --inject-stdout-noise --inject-stderr-noise' }}"
- ansible_become_password: "{{ intermediate_user_password }}"
- register: preamble_visible
-
- - assert:
- that:
- - preamble_visible.stdout is contains(target_user_name ~ " ran")
- - preamble_visible.stdout is contains "stdout noise"
- - preamble_visible.stderr is contains "stderr noise"
-
- - name: verify sudo succeeds with a password
- raw: echo $(whoami) ran
- vars:
- ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}"
- ansible_become_password: "{{ intermediate_user_password }}"
- register: success
-
- - assert:
- that:
- - success.stdout is contains(target_user_name ~ " ran")
- - success.stderr is search 'sudo via ansible.*password\:'
-
- - name: verify early-closed stderr still sees success
- # this test triggers early EOF (which unregisters the associated selector) on most OSs, but not on Ubuntu 24.04
- vars:
- ansible_become_flags: --close-stderr --sleep-before-sudo --pretend-to-be-sudo
- raw: echo ran_ok
- register: stderr_closed
-
- - assert:
- that:
- - stderr_closed.stderr is contains "some injected stderr, EOF now"
- - stderr_closed.stdout is contains "ran_ok"
-
- - name: verify timeout handling by setting a sudo prompt that won't trigger password send
- vars:
- ansible_become_flags: "{{ default_sudo_flags ~ intermediate }} --bogus-prompt"
- raw: exit 99
- ignore_errors: true # TODO: Make aoh return AnsibleConnectionFailure on become timeout?
- register: prompt_timeout
-
- - assert:
- that:
- # - prompt_timeout is unreachable # AOH
- - prompt_timeout.msg is contains "Timed out waiting for become success or become password prompt"
- - prompt_timeout.msg is contains "BOGUSPROMPT"
- - prompt_timeout.rc is not defined
-
- - name: verify handling of premature exit/stream closure
- vars:
- ansible_become_exe: /bogus
- raw: exit 99
- ignore_errors: true
- register: early_close
-
- - assert:
- that:
- - early_close is failed
- - early_close.msg is contains "Premature end of stream"
-
- - name: verify lack of required password fails as expected
- raw: exit 99
- vars:
- ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}"
- ansible_become_password: ~
- ignore_errors: true
- register: missing_required_password
-
- - assert:
- that:
- - missing_required_password is failed
- - missing_required_password.msg is contains "password is required" or missing_required_password.msg is contains "interactive authentication is required"
-
- - name: verify duplicate password prompts are handled (due to incorrect password)
- raw: echo hi mom
- vars:
- ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}"
- ansible_become_password: not_the_correct_password
- ignore_errors: yes
- register: incorrect_password
-
- - assert:
- that:
- - incorrect_password is failed
- - incorrect_password.msg is contains "Duplicate become password prompt encountered"
-
- - name: no error, but no become success message
- vars:
- ansible_become_flags: --pretend-to-be-broken-sudo # handle password prompt, but return no output
- raw: exit 99 # should never actually run anyway
- ignore_errors: true
- register: no_become_success
-
- - assert:
- that:
- - no_become_success is failed
- - no_become_success.msg is contains "Premature end of stream waiting for become success"
-
- - name: test broken passwordless sudo
- raw: echo hi mom
- vars:
- ansible_become_flags: --pretend-to-be-broken-passwordless-sudo
- ignore_errors: yes
- register: broken_passwordless_sudo
-
- - assert:
- that:
- - broken_passwordless_sudo is failed
- - broken_passwordless_sudo.msg is contains "not a module response"
diff --git a/tests/e2e/test_become_unix.yml b/tests/e2e/test_become_unix.yml
@@ -0,0 +1,158 @@
+# Adapted from Ansible's test/integration/targets/connection_local/test_become_password_handling.yml
+
+- hosts: aoh
+ tasks:
+ - name: skip this test for Windows hosts / non-root users
+ block:
+ - debug:
+ msg: Skipping sudo test for Windows host / non-root user.
+ - meta: end_play
+ when: ansible_facts.user_id != 'root'
+
+ - name: attempt root-to-root sudo become (no-op, should succeed)
+ vars:
+ ansible_become: yes
+ ansible_become_user: root
+ ansible_become_method: sudo
+ raw: whoami
+ register: root_noop_sudo
+
+ - assert:
+ that:
+ - root_noop_sudo.stdout is contains "root"
+
+ - name: create an unprivileged become user pair
+ include_role:
+ name: setup_become_user_pair
+ public: true
+
+ - name: deploy sudo shim
+ copy:
+ src: sudoshim.sh
+ dest: /tmp/sudoshim.sh
+ mode: u+x
+
+ - set_fact:
+ default_sudo_flags: "{{ lookup('config', 'become_flags', plugin_type='become', plugin_name='sudo') }}"
+
+ - name: apply shared become vars to all tasks that use the sudo test shim
+ vars:
+ ansible_become: yes
+ ansible_become_method: sudo
+ ansible_become_user: '{{ target_user_name }}'
+ ansible_become_password: '{{ target_user_password }}'
+ ansible_become_exe: /tmp/sudoshim.sh
+ intermediate: "{{ ((' --intermediate-user ' + intermediate_user_name) if intermediate_user_name is defined else '') }}"
+ block:
+ - name: verify stdout/stderr noise is present
+ raw: echo $(whoami) ran
+ vars:
+ ansible_become_flags: "{{ default_sudo_flags ~ intermediate ~ ' --inject-stdout-noise --inject-stderr-noise' }}"
+ ansible_become_password: "{{ intermediate_user_password }}"
+ register: preamble_visible
+
+ - assert:
+ that:
+ - preamble_visible.stdout is contains(target_user_name ~ " ran")
+ - preamble_visible.stdout is contains "stdout noise"
+ - preamble_visible.stderr is contains "stderr noise"
+
+ - name: verify sudo succeeds with a password
+ raw: echo $(whoami) ran
+ vars:
+ ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}"
+ ansible_become_password: "{{ intermediate_user_password }}"
+ register: success
+
+ - assert:
+ that:
+ - success.stdout is contains(target_user_name ~ " ran")
+ - success.stderr is search 'sudo via ansible.*password\:'
+
+ - name: verify early-closed stderr still sees success
+ # this test triggers early EOF (which unregisters the associated selector) on most OSs, but not on Ubuntu 24.04
+ vars:
+ ansible_become_flags: --close-stderr --sleep-before-sudo --pretend-to-be-sudo
+ raw: echo ran_ok
+ register: stderr_closed
+
+ - assert:
+ that:
+ - stderr_closed.stderr is contains "some injected stderr, EOF now"
+ - stderr_closed.stdout is contains "ran_ok"
+
+ - name: verify timeout handling by setting a sudo prompt that won't trigger password send
+ vars:
+ ansible_become_flags: "{{ default_sudo_flags ~ intermediate }} --bogus-prompt"
+ raw: exit 99
+ ignore_errors: true # TODO: Make aoh return AnsibleConnectionFailure on become timeout?
+ register: prompt_timeout
+
+ - assert:
+ that:
+ # - prompt_timeout is unreachable # AOH
+ - prompt_timeout.msg is contains "Timed out waiting for become success or become password prompt"
+ - prompt_timeout.msg is contains "BOGUSPROMPT"
+ - prompt_timeout.rc is not defined
+
+ - name: verify handling of premature exit/stream closure
+ vars:
+ ansible_become_exe: /bogus
+ raw: exit 99
+ ignore_errors: true
+ register: early_close
+
+ - assert:
+ that:
+ - early_close is failed
+ - early_close.msg is contains "Premature end of stream"
+
+ - name: verify lack of required password fails as expected
+ raw: exit 99
+ vars:
+ ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}"
+ ansible_become_password: ~
+ ignore_errors: true
+ register: missing_required_password
+
+ - assert:
+ that:
+ - missing_required_password is failed
+ - missing_required_password.msg is contains "password is required" or missing_required_password.msg is contains "interactive authentication is required"
+
+ - name: verify duplicate password prompts are handled (due to incorrect password)
+ raw: echo hi mom
+ vars:
+ ansible_become_flags: "{{ default_sudo_flags ~ intermediate }}"
+ ansible_become_password: not_the_correct_password
+ ignore_errors: yes
+ register: incorrect_password
+
+ - assert:
+ that:
+ - incorrect_password is failed
+ - incorrect_password.msg is contains "Duplicate become password prompt encountered"
+
+ - name: no error, but no become success message
+ vars:
+ ansible_become_flags: --pretend-to-be-broken-sudo # handle password prompt, but return no output
+ raw: exit 99 # should never actually run anyway
+ ignore_errors: true
+ register: no_become_success
+
+ - assert:
+ that:
+ - no_become_success is failed
+ - no_become_success.msg is contains "Premature end of stream waiting for become success"
+
+ - name: test broken passwordless sudo
+ raw: echo hi mom
+ vars:
+ ansible_become_flags: --pretend-to-be-broken-passwordless-sudo
+ ignore_errors: yes
+ register: broken_passwordless_sudo
+
+ - assert:
+ that:
+ - broken_passwordless_sudo is failed
+ - broken_passwordless_sudo.msg is contains "not a module response"
diff --git a/tests/e2e/test_become_windows.yml b/tests/e2e/test_become_windows.yml
@@ -0,0 +1,21 @@
+# Adapted from Ansible's test/integration/targets/connection_windows_ssh/tests.yml
+
+- hosts: aoh
+ tasks:
+ - name: skip runas test for Unix hosts
+ when: ansible_facts.os_family != 'Windows'
+ block:
+ - debug:
+ msg: Skipping test for Unix host.
+ - meta: end_play
+
+ - name: test become on Windows
+ win_whoami:
+ register: win_become
+ become: yes
+ become_method: runas
+ become_user: SYSTEM
+
+ - name: assert become successful on Windows
+ assert:
+ that: win_become.account.sid == "S-1-5-18"
diff --git a/tests/e2e/test_connection.yml b/tests/e2e/test_connection.yml
@@ -1,12 +1,17 @@
# Adapted from Ansible's test/integration/targets/connection/test_connection.yml
- hosts: aoh
- gather_facts: no
serial: 1
vars:
- action_prefix: ''
+ action_prefix: >-
+ {{ 'win_' if ansible_facts.os_family == 'Windows' else '' }}
local_tmp: /tmp/ansible-local
- remote_tmp: /tmp/ansible-remote
+ remote_tmp: >-
+ {{
+ 'C:/windows/temp/ansible-remote'
+ if ansible_facts.os_family == 'Windows'
+ else '/tmp/ansible-remote'
+ }}
tasks: