mirror of https://github.com/ansible/ansible.git
Add support for Windows hosts in the SSH connection plugin (#47732)
* Add support for Windows hosts in the SSH connection plugin * fix Python 2.6 unit test and sanity issues * fix up connection tests in CI, disable SCP for now * ensure we don't pollute the existing environment during the test * Add connection_windows_ssh to classifier * use test dir for inventory file * Required powershell as default shell and fix tests * Remove exlicit become_methods on connection * clarify console encoding comment * ignore recent SCP errors in integration tests * Add cmd shell type and added more tests * Fix some doc issues * revises windows faq * add anchors for windows links * revises windows setup page * Update changelogs/fragments/windows-ssh.yaml Co-Authored-By: jborean93 <jborean93@gmail.com>pull/53499/head
parent
cdf475e830
commit
8ef2e6da05
@ -0,0 +1,2 @@
|
|||||||
|
minor_changes:
|
||||||
|
- Added experimental support for connecting to Windows hosts over SSH using ``ansible_shell_type=cmd`` or ``ansible_shell_type=powershell``
|
@ -0,0 +1,47 @@
|
|||||||
|
# Copyright (c) 2019 Ansible Project
|
||||||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleDocFragment(object):
|
||||||
|
|
||||||
|
# Windows shell documentation fragment
|
||||||
|
# FIXME: set_module_language don't belong here but must be set so they don't fail when someone
|
||||||
|
# get_option('set_module_language') on this plugin
|
||||||
|
DOCUMENTATION = """
|
||||||
|
options:
|
||||||
|
async_dir:
|
||||||
|
description:
|
||||||
|
- Directory in which ansible will keep async job information.
|
||||||
|
- Before Ansible 2.8, this was set to C(remote_tmp + "\\.ansible_async").
|
||||||
|
default: '%USERPROFILE%\\.ansible_async'
|
||||||
|
ini:
|
||||||
|
- section: powershell
|
||||||
|
key: async_dir
|
||||||
|
vars:
|
||||||
|
- name: ansible_async_dir
|
||||||
|
version_added: '2.8'
|
||||||
|
remote_tmp:
|
||||||
|
description:
|
||||||
|
- Temporary directory to use on targets when copying files to the host.
|
||||||
|
default: '%TEMP%'
|
||||||
|
ini:
|
||||||
|
- section: powershell
|
||||||
|
key: remote_tmp
|
||||||
|
vars:
|
||||||
|
- name: ansible_remote_tmp
|
||||||
|
set_module_language:
|
||||||
|
description:
|
||||||
|
- Controls if we set the locale for moduels when executing on the
|
||||||
|
target.
|
||||||
|
- Windows only supports C(no) as an option.
|
||||||
|
type: bool
|
||||||
|
default: 'no'
|
||||||
|
choices:
|
||||||
|
- 'no'
|
||||||
|
environment:
|
||||||
|
description:
|
||||||
|
- Dictionary of environment variables and their values to use when
|
||||||
|
executing commands.
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
"""
|
@ -0,0 +1,57 @@
|
|||||||
|
# Copyright (c) 2019 Ansible Project
|
||||||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
DOCUMENTATION = '''
|
||||||
|
name: cmd
|
||||||
|
plugin_type: shell
|
||||||
|
version_added: '2.8'
|
||||||
|
short_description: Windows Command Prompt
|
||||||
|
description:
|
||||||
|
- Used with the 'ssh' connection plugin and no C(DefaultShell) has been set on the Windows host.
|
||||||
|
extends_documentation_fragment:
|
||||||
|
- shell_windows
|
||||||
|
'''
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ansible.plugins.shell.powershell import ShellModule as PSShellModule
|
||||||
|
|
||||||
|
# these are the metachars that have a special meaning in cmd that we want to escape when quoting
|
||||||
|
_find_unsafe = re.compile(r'[\s\(\)\%\!^\"\<\>\&\|]').search
|
||||||
|
|
||||||
|
|
||||||
|
class ShellModule(PSShellModule):
|
||||||
|
|
||||||
|
# Common shell filenames that this plugin handles
|
||||||
|
COMPATIBLE_SHELLS = frozenset()
|
||||||
|
# Family of shells this has. Must match the filename without extension
|
||||||
|
SHELL_FAMILY = 'cmd'
|
||||||
|
|
||||||
|
_SHELL_REDIRECT_ALLNULL = '>nul 2>&1'
|
||||||
|
_SHELL_AND = '&&'
|
||||||
|
|
||||||
|
# Used by various parts of Ansible to do Windows specific changes
|
||||||
|
_IS_WINDOWS = True
|
||||||
|
|
||||||
|
def quote(self, s):
|
||||||
|
# cmd does not support single quotes that the shlex_quote uses. We need to override the quoting behaviour to
|
||||||
|
# better match cmd.exe.
|
||||||
|
# https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
|
||||||
|
|
||||||
|
# Return an empty argument
|
||||||
|
if not s:
|
||||||
|
return '""'
|
||||||
|
|
||||||
|
if _find_unsafe(s) is None:
|
||||||
|
return s
|
||||||
|
|
||||||
|
# Escape the metachars as we are quoting the string to stop cmd from interpreting that metachar. For example
|
||||||
|
# 'file &whoami.exe' would result in 'file $(whoami.exe)' instead of the literal string
|
||||||
|
# https://stackoverflow.com/questions/3411771/multiple-character-replace-with-python
|
||||||
|
for c in '^()%!"<>&|': # '^' must be the first char that we scan and replace
|
||||||
|
if c in s:
|
||||||
|
s = s.replace(c, "^" + c)
|
||||||
|
|
||||||
|
return '^"' + s + '^"'
|
@ -0,0 +1,6 @@
|
|||||||
|
windows
|
||||||
|
shippable/windows/group1
|
||||||
|
shippable/windows/smoketest
|
||||||
|
skip/windows/2008 # Windows Server 2008 does not support Win32-OpenSSH
|
||||||
|
needs/target/connection
|
||||||
|
needs/target/setup_remote_tmp_dir
|
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eux
|
||||||
|
|
||||||
|
# We need to run these tests with both the powershell and cmd shell type
|
||||||
|
|
||||||
|
### cmd tests - no DefaultShell set ###
|
||||||
|
ansible -i ../../inventory.winrm localhost \
|
||||||
|
-m template \
|
||||||
|
-a "src=test_connection.inventory.j2 dest=~/ansible_testing/test_connection.inventory" \
|
||||||
|
-e "test_shell_type=cmd" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# https://github.com/PowerShell/Win32-OpenSSH/wiki/DefaultShell
|
||||||
|
ansible -i ../../inventory.winrm windows \
|
||||||
|
-m win_regedit \
|
||||||
|
-a "path=HKLM:\\\\SOFTWARE\\\\OpenSSH name=DefaultShell state=absent" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Need to flush the connection to ensure we get a new shell for the next tests
|
||||||
|
ansible -i ~/ansible_testing/test_connection.inventory windows-ssh \
|
||||||
|
-m meta -a "reset_connection" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# sftp
|
||||||
|
./windows.sh "$@"
|
||||||
|
# scp
|
||||||
|
ANSIBLE_SCP_IF_SSH=true ./windows.sh "$@"
|
||||||
|
# other tests not part of the generic connection test framework
|
||||||
|
ansible-playbook -i ~/ansible_testing/test_connection.inventory tests.yml \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
### powershell tests - explicit DefaultShell set ###
|
||||||
|
# we do this last as the default shell on our CI instances is set to PowerShell
|
||||||
|
ansible -i ../../inventory.winrm localhost \
|
||||||
|
-m template \
|
||||||
|
-a "src=test_connection.inventory.j2 dest=~/ansible_testing/test_connection.inventory" \
|
||||||
|
-e "test_shell_type=powershell" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# ensure the default shell is set to PowerShell
|
||||||
|
ansible -i ../../inventory.winrm windows \
|
||||||
|
-m win_regedit \
|
||||||
|
-a "path=HKLM:\\\\SOFTWARE\\\\OpenSSH name=DefaultShell data=C:\\\\Windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
ansible -i ~/ansible_testing/test_connection.inventory windows-ssh \
|
||||||
|
-m meta -a "reset_connection" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
./windows.sh "$@"
|
||||||
|
ANSIBLE_SCP_IF_SSH=true ./windows.sh "$@"
|
||||||
|
ansible-playbook -i ~/ansible_testing/test_connection.inventory tests.yml \
|
||||||
|
"$@"
|
@ -0,0 +1,12 @@
|
|||||||
|
[windows-ssh]
|
||||||
|
{% for host in vars.groups.winrm %}
|
||||||
|
{{ host }} ansible_host={{ hostvars[host]['ansible_host'] }} ansible_user={{ hostvars[host]['ansible_user'] }}{{ ' ansible_ssh_private_key_file=' ~ hostvars[host]['ansible_ssh_private_key_file'] if (hostvars[host]['ansible_ssh_private_key_file']|default()) else '' }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
[windows-ssh:vars]
|
||||||
|
ansible_shell_type={{ test_shell_type }}
|
||||||
|
ansible_connection=ssh
|
||||||
|
ansible_port=22
|
||||||
|
# used to preserve the existing environment and not touch existing files
|
||||||
|
ansible_ssh_extra_args="-o UserKnownHostsFile=/dev/null"
|
||||||
|
ansible_ssh_host_key_checking=False
|
@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
- name: test out Windows SSH specific tests
|
||||||
|
hosts: windows-ssh
|
||||||
|
serial: 1
|
||||||
|
gather_facts: no
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: test out become with Windows SSH
|
||||||
|
win_whoami:
|
||||||
|
register: win_ssh_become
|
||||||
|
become: yes
|
||||||
|
become_method: runas
|
||||||
|
become_user: SYSTEM
|
||||||
|
|
||||||
|
- name: assert test out become with Windows SSH
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- win_ssh_become.account.sid == "S-1-5-18"
|
||||||
|
|
||||||
|
- name: test out async with Windows SSH
|
||||||
|
win_shell: Write-Host café
|
||||||
|
async: 20
|
||||||
|
poll: 3
|
||||||
|
register: win_ssh_async
|
||||||
|
|
||||||
|
- name: assert test out async with Windows SSH
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- win_ssh_async is changed
|
||||||
|
- win_ssh_async.rc == 0
|
||||||
|
- win_ssh_async.stdout == "café\n"
|
||||||
|
- win_ssh_async.stderr == ""
|
@ -0,0 +1,41 @@
|
|||||||
|
# This must be a play as we need to invoke it with the ANSIBLE_SCP_IF_SSH env
|
||||||
|
# to control the mechanism used. Unfortunately while ansible_scp_if_ssh is
|
||||||
|
# documented, it isn't actually used hence the separate invocation
|
||||||
|
---
|
||||||
|
- name: further fetch tests with metachar characters in filename
|
||||||
|
hosts: windows-ssh
|
||||||
|
force_handlers: yes
|
||||||
|
serial: 1
|
||||||
|
gather_facts: no
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: setup remote tmp dir
|
||||||
|
import_role:
|
||||||
|
name: ../../setup_remote_tmp_dir
|
||||||
|
|
||||||
|
- name: create remote file with metachar in name
|
||||||
|
win_copy:
|
||||||
|
content: some content
|
||||||
|
dest: '{{ remote_tmp_dir }}\file ^with &whoami'
|
||||||
|
|
||||||
|
- name: test fetch against a file with cmd metacharacters
|
||||||
|
block:
|
||||||
|
- name: fetch file with metachar in name
|
||||||
|
fetch:
|
||||||
|
src: '{{ remote_tmp_dir }}\file ^with &whoami'
|
||||||
|
dest: ansible-test.txt
|
||||||
|
flat: yes
|
||||||
|
register: fetch_res
|
||||||
|
|
||||||
|
- name: assert fetch file with metachar in name
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- fetch_res is changed
|
||||||
|
- fetch_res.checksum == '94e66df8cd09d410c62d9e0dc59d3a884e458e05'
|
||||||
|
|
||||||
|
always:
|
||||||
|
- name: remove local copy of file
|
||||||
|
file:
|
||||||
|
path: ansible-test.txt
|
||||||
|
state: absent
|
||||||
|
delegate_to: localhost
|
@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eux
|
||||||
|
|
||||||
|
cd ../connection
|
||||||
|
|
||||||
|
# A recent patch to OpenSSH causes a validation error when running through Ansible. It seems like if the path is quoted
|
||||||
|
# then it will fail with 'protocol error: filename does not match request'. We currently ignore this by setting
|
||||||
|
# 'ansible_scp_extra_args=-T' to ignore this check but this should be removed once that bug is fixed and our test
|
||||||
|
# container has been updated.
|
||||||
|
# https://unix.stackexchange.com/questions/499958/why-does-scps-strict-filename-checking-reject-quoted-last-component-but-not-oth
|
||||||
|
# https://github.com/openssh/openssh-portable/commit/391ffc4b9d31fa1f4ad566499fef9176ff8a07dc
|
||||||
|
INVENTORY=~/ansible_testing/test_connection.inventory ./test.sh \
|
||||||
|
-e target_hosts=windows-ssh \
|
||||||
|
-e action_prefix=win_ \
|
||||||
|
-e local_tmp=/tmp/ansible-local \
|
||||||
|
-e remote_tmp=c:/windows/temp/ansible-remote \
|
||||||
|
-e ansible_scp_extra_args=-T \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
cd ../connection_windows_ssh
|
||||||
|
|
||||||
|
ansible-playbook -i ~/ansible_testing/test_connection.inventory tests_fetch.yml \
|
||||||
|
-e ansible_scp_extra_args=-T \
|
||||||
|
"$@"
|
@ -0,0 +1,16 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ansible.plugins.shell.cmd import ShellModule
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('s, expected', [
|
||||||
|
['arg1', 'arg1'],
|
||||||
|
[None, '""'],
|
||||||
|
['arg1 and 2', '^"arg1 and 2^"'],
|
||||||
|
['malicious argument\\"&whoami', '^"malicious argument\\^"^&whoami^"'],
|
||||||
|
['C:\\temp\\some ^%file% > nul', '^"C:\\temp\\some ^^^%file^% ^> nul^"']
|
||||||
|
])
|
||||||
|
def test_quote_args(s, expected):
|
||||||
|
cmd = ShellModule()
|
||||||
|
actual = cmd.quote(s)
|
||||||
|
assert actual == expected
|
@ -0,0 +1,53 @@
|
|||||||
|
from ansible.plugins.shell.powershell import _parse_clixml
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_clixml_empty():
|
||||||
|
empty = b'#< CLIXML\r\n<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"></Objs>'
|
||||||
|
expected = b''
|
||||||
|
actual = _parse_clixml(empty)
|
||||||
|
assert actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_clixml_with_progress():
|
||||||
|
progress = b'#< CLIXML\r\n<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">' \
|
||||||
|
b'<Obj S="progress" RefId="0"><TN RefId="0"><T>System.Management.Automation.PSCustomObject</T><T>System.Object</T></TN><MS>' \
|
||||||
|
b'<I64 N="SourceId">1</I64><PR N="Record"><AV>Preparing modules for first use.</AV><AI>0</AI><Nil />' \
|
||||||
|
b'<PI>-1</PI><PC>-1</PC><T>Completed</T><SR>-1</SR><SD> </SD></PR></MS></Obj></Objs>'
|
||||||
|
expected = b''
|
||||||
|
actual = _parse_clixml(progress)
|
||||||
|
assert actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_clixml_single_stream():
|
||||||
|
single_stream = b'#< CLIXML\r\n<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">' \
|
||||||
|
b'<S S="Error">fake : The term \'fake\' is not recognized as the name of a cmdlet. Check _x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error">the spelling of the name, or if a path was included._x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error">At line:1 char:1_x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error">+ fake cmdlet_x000D__x000A_</S><S S="Error">+ ~~~~_x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error"> + CategoryInfo : ObjectNotFound: (fake:String) [], CommandNotFoundException_x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error"> + FullyQualifiedErrorId : CommandNotFoundException_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S>' \
|
||||||
|
b'</Objs>'
|
||||||
|
expected = b"fake : The term 'fake' is not recognized as the name of a cmdlet. Check \r\n" \
|
||||||
|
b"the spelling of the name, or if a path was included.\r\n" \
|
||||||
|
b"At line:1 char:1\r\n" \
|
||||||
|
b"+ fake cmdlet\r\n" \
|
||||||
|
b"+ ~~~~\r\n" \
|
||||||
|
b" + CategoryInfo : ObjectNotFound: (fake:String) [], CommandNotFoundException\r\n" \
|
||||||
|
b" + FullyQualifiedErrorId : CommandNotFoundException\r\n "
|
||||||
|
actual = _parse_clixml(single_stream)
|
||||||
|
assert actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_clixml_multiple_streams():
|
||||||
|
multiple_stream = b'#< CLIXML\r\n<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">' \
|
||||||
|
b'<S S="Error">fake : The term \'fake\' is not recognized as the name of a cmdlet. Check _x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error">the spelling of the name, or if a path was included._x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error">At line:1 char:1_x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error">+ fake cmdlet_x000D__x000A_</S><S S="Error">+ ~~~~_x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error"> + CategoryInfo : ObjectNotFound: (fake:String) [], CommandNotFoundException_x000D__x000A_</S>' \
|
||||||
|
b'<S S="Error"> + FullyQualifiedErrorId : CommandNotFoundException_x000D__x000A_</S><S S="Error"> _x000D__x000A_</S>' \
|
||||||
|
b'<S S="Info">hi info</S>' \
|
||||||
|
b'</Objs>'
|
||||||
|
expected = b"hi info"
|
||||||
|
actual = _parse_clixml(multiple_stream, stream="Info")
|
||||||
|
assert actual == expected
|
Loading…
Reference in New Issue