aoh

A tool for running Ansible playbooks with `curl <url> | python` commands
git clone https://git.ashermorgan.net/aoh/
Log | Files | Refs | README

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:
M.github/workflows/ci-cd.yml | 46++++++++++++++++++++++++++++++++++++++++++++--
MREADME.md | 9+++------
Maoh/client.py | 3+++
Mtests/e2e/playbooks.yml | 13+++++++++----
Atests/e2e/run.ps1 | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/e2e/run.sh | 6+++---
Dtests/e2e/test_become_password_handling.yml | 158-------------------------------------------------------------------------------
Atests/e2e/test_become_unix.yml | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/test_become_windows.yml | 21+++++++++++++++++++++
Mtests/e2e/test_connection.yml | 11++++++++---
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: