mirror of
https://github.com/ansible/ansible.git
synced 2026-07-29 16:35:20 +02:00
ansible-test - Support pwsh version selection (#86238)
Although this change exposes `ansible_pwsh_interpreter` in inventory generated by ansible-test, a follow-up change is needed to enable its usage.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
minor_changes:
|
||||
- ansible-test - Add PowerShell support to managed containers and remotes.
|
||||
|
||||
bugfixes:
|
||||
- ansible-test - Add missing bootstrapping for a target Python version in a controller container.
|
||||
@@ -0,0 +1,4 @@
|
||||
context/controller
|
||||
gather_facts/no
|
||||
shippable/posix/group5
|
||||
shippable/generic/group1 # runs in the default test container
|
||||
@@ -0,0 +1,24 @@
|
||||
- name: Find pwsh on PATH
|
||||
command: which pwsh
|
||||
register: pwsh_location
|
||||
ignore_errors: yes
|
||||
|
||||
- when: pwsh_location is success
|
||||
block:
|
||||
- name: Get pwsh version
|
||||
command: "{{ pwsh_location.stdout | trim | quote }} --version"
|
||||
register: pwsh_version
|
||||
|
||||
- name: Show pwsh location and version
|
||||
debug:
|
||||
msg: "{{ pwsh_location.stdout | trim }} is {{ pwsh_version.stdout | trim }}"
|
||||
|
||||
- when: ansible_pwsh_interpreter is defined
|
||||
block:
|
||||
- name: Get ansible_pwsh_interpreter version
|
||||
command: "{{ ansible_pwsh_interpreter | quote }} --version"
|
||||
register: ansible_pwsh_interpreter_version
|
||||
|
||||
- name: Show ansible_pwsh_interpreter location and version
|
||||
debug:
|
||||
msg: "{{ ansible_pwsh_interpreter }} is {{ ansible_pwsh_interpreter_version.stdout | trim }}"
|
||||
@@ -0,0 +1,4 @@
|
||||
context/target
|
||||
gather_facts/no
|
||||
shippable/posix/group2
|
||||
shippable/generic/group1 # runs in the default test container
|
||||
@@ -0,0 +1,24 @@
|
||||
- name: Find pwsh on PATH
|
||||
command: which pwsh
|
||||
register: pwsh_location
|
||||
ignore_errors: yes
|
||||
|
||||
- when: pwsh_location is success
|
||||
block:
|
||||
- name: Get pwsh version
|
||||
command: "{{ pwsh_location.stdout | trim | quote }} --version"
|
||||
register: pwsh_version
|
||||
|
||||
- name: Show pwsh location and version
|
||||
debug:
|
||||
msg: "{{ pwsh_location.stdout | trim }} is {{ pwsh_version.stdout | trim }}"
|
||||
|
||||
- when: ansible_pwsh_interpreter is defined
|
||||
block:
|
||||
- name: Get ansible_pwsh_interpreter version
|
||||
command: "{{ ansible_pwsh_interpreter | quote }} --version"
|
||||
register: ansible_pwsh_interpreter_version
|
||||
|
||||
- name: Show ansible_pwsh_interpreter location and version
|
||||
debug:
|
||||
msg: "{{ ansible_pwsh_interpreter }} is {{ ansible_pwsh_interpreter_version.stdout | trim }}"
|
||||
@@ -0,0 +1,5 @@
|
||||
context/target
|
||||
gather_facts/no
|
||||
shippable/windows/group1
|
||||
shippable/windows/smoketest
|
||||
windows
|
||||
@@ -0,0 +1,23 @@
|
||||
- name: Find powershell location
|
||||
win_shell: (Get-Command powershell).source
|
||||
register: powershell_location
|
||||
|
||||
- name: Get powershell version
|
||||
win_command: |
|
||||
"{{ powershell_location.stdout | trim }}" -Command "$PSVersionTable.PSVersion.ToString()"
|
||||
register: powershell_version
|
||||
|
||||
- name: Show powershell location and version
|
||||
debug:
|
||||
msg: "{{ powershell_location.stdout | trim }} is {{ powershell_version.stdout | trim }}"
|
||||
|
||||
- when: ansible_pwsh_interpreter is defined
|
||||
block:
|
||||
- name: Get ansible_pwsh_interpreter version
|
||||
win_command: |
|
||||
"{{ ansible_pwsh_interpreter }}" -Command "$PSVersionTable.PSVersion.ToString()"
|
||||
register: ansible_pwsh_interpreter_version
|
||||
|
||||
- name: Show ansible_pwsh_interpreter location and version
|
||||
debug:
|
||||
msg: "{{ ansible_pwsh_interpreter }} is {{ ansible_pwsh_interpreter_version.stdout | trim }}"
|
||||
@@ -1,3 +0,0 @@
|
||||
context/target
|
||||
gather_facts/no
|
||||
shippable/posix/group2
|
||||
@@ -1,8 +0,0 @@
|
||||
- name: Get PowerShell version
|
||||
command: pwsh --version
|
||||
register: powershell_version
|
||||
failed_when: false
|
||||
|
||||
- name: Show PowerShell Version
|
||||
debug:
|
||||
msg: "{{ powershell_version.stdout }}"
|
||||
@@ -1,7 +1,7 @@
|
||||
base image=quay.io/ansible/base-test-container:v2.21-0 python=3.14,3.9,3.10,3.11,3.12,3.13
|
||||
default image=quay.io/ansible/default-test-container:v2.21-0 python=3.14,3.9,3.10,3.11,3.12,3.13 context=collection
|
||||
default image=quay.io/ansible/ansible-core-test-container:v2.21-0 python=3.14,3.9,3.10,3.11,3.12,3.13 context=ansible-core
|
||||
base image=quay.io/ansible/base-test-container:v2.21-2 python=3.14,3.9,3.10,3.11,3.12,3.13 powershell=7.6
|
||||
default image=quay.io/ansible/default-test-container:v2.21-2 python=3.14,3.9,3.10,3.11,3.12,3.13 powershell=7.6 context=collection
|
||||
default image=quay.io/ansible/ansible-core-test-container:v2.21-2 python=3.14,3.9,3.10,3.11,3.12,3.13 powershell=7.6 context=ansible-core
|
||||
alpine323 image=quay.io/ansible/alpine-test-container:3.23-v2.21-1 python=3.12 cgroup=none audit=none
|
||||
fedora43 image=quay.io/ansible/fedora-test-container:43-v2.21-1 python=3.14 cgroup=v2-only
|
||||
fedora43 image=quay.io/ansible/fedora-test-container:43-v2.21-2 python=3.14 cgroup=v2-only
|
||||
ubuntu2204 image=quay.io/ansible/ubuntu-test-container:22.04-v2.20-1 python=3.10
|
||||
ubuntu2404 image=quay.io/ansible/ubuntu-test-container:24.04-v2.20-1 python=3.12
|
||||
|
||||
@@ -30,7 +30,7 @@ from .util_common import (
|
||||
create_temp_dir,
|
||||
ResultType,
|
||||
intercept_python,
|
||||
get_injector_path,
|
||||
get_python_injector_path,
|
||||
)
|
||||
|
||||
from .config import (
|
||||
@@ -122,7 +122,7 @@ def ansible_environment(args: CommonConfig, color: bool = True, ansible_config:
|
||||
# it only requires the injector for code coverage
|
||||
# the correct python interpreter is already selected using the sys.executable used to invoke ansible
|
||||
ansible.update(
|
||||
_ANSIBLE_CONNECTION_PATH=os.path.join(get_injector_path(), 'ansible_connection_cli_stub.py'),
|
||||
_ANSIBLE_CONNECTION_PATH=os.path.join(get_python_injector_path(), 'ansible_connection_cli_stub.py'),
|
||||
)
|
||||
|
||||
if isinstance(args, PosixIntegrationConfig):
|
||||
|
||||
@@ -24,13 +24,14 @@ from .core_ci import (
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@dataclasses.dataclass(kw_only=True)
|
||||
class Bootstrap:
|
||||
"""Base class for bootstrapping systems."""
|
||||
|
||||
controller: bool
|
||||
python_interpreters: dict[str, str]
|
||||
ssh_key: SshKey
|
||||
powershell_versions: list[str]
|
||||
|
||||
@property
|
||||
def bootstrap_type(self) -> str:
|
||||
@@ -46,6 +47,7 @@ class Bootstrap:
|
||||
ssh_key_type=self.ssh_key.KEY_TYPE,
|
||||
ssh_private_key=self.ssh_key.key_contents,
|
||||
ssh_public_key=self.ssh_key.pub_contents,
|
||||
powershell_versions=self.powershell_versions,
|
||||
)
|
||||
|
||||
def get_script(self) -> str:
|
||||
@@ -64,7 +66,7 @@ class Bootstrap:
|
||||
return script
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@dataclasses.dataclass(kw_only=True)
|
||||
class BootstrapDocker(Bootstrap):
|
||||
"""Bootstrap docker instances."""
|
||||
|
||||
@@ -80,7 +82,7 @@ class BootstrapDocker(Bootstrap):
|
||||
return variables
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@dataclasses.dataclass(kw_only=True)
|
||||
class BootstrapRemote(Bootstrap):
|
||||
"""Bootstrap remote instances."""
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ from ..argparsing.parsers import (
|
||||
|
||||
from .value_parsers import (
|
||||
PythonParser,
|
||||
PowerShellParser,
|
||||
PowerShellPathParser,
|
||||
)
|
||||
|
||||
from .helpers import (
|
||||
@@ -61,6 +63,7 @@ class OriginKeyValueParser(KeyValueParser):
|
||||
|
||||
return dict(
|
||||
python=PythonParser(versions=versions, allow_venv=True, allow_default=True),
|
||||
powershell=PowerShellParser(),
|
||||
)
|
||||
|
||||
def document(self, state: DocumentationState) -> t.Optional[str]:
|
||||
@@ -71,6 +74,7 @@ class OriginKeyValueParser(KeyValueParser):
|
||||
|
||||
state.sections[f'controller {section_name} (comma separated):'] = '\n'.join([
|
||||
f' python={python_parser.document(state)}',
|
||||
f' powershell={PowerShellParser().document(state)}',
|
||||
])
|
||||
|
||||
return f'{{{section_name}}} # default'
|
||||
@@ -87,6 +91,7 @@ class ControllerKeyValueParser(KeyValueParser):
|
||||
|
||||
return dict(
|
||||
python=PythonParser(versions=versions, allow_venv=allow_venv, allow_default=allow_default),
|
||||
powershell=PowerShellParser(),
|
||||
)
|
||||
|
||||
def document(self, state: DocumentationState) -> t.Optional[str]:
|
||||
@@ -96,6 +101,7 @@ class ControllerKeyValueParser(KeyValueParser):
|
||||
state.sections[f'target {section_name} (comma separated):'] = '\n'.join([
|
||||
f' python={PythonParser(SUPPORTED_PYTHON_VERSIONS, allow_venv=False, allow_default=True).document(state)} # non-origin controller',
|
||||
f' python={PythonParser(SUPPORTED_PYTHON_VERSIONS, allow_venv=True, allow_default=True).document(state)} # origin controller',
|
||||
f' powershell={PowerShellParser().document(state)}',
|
||||
])
|
||||
|
||||
return f'{{{section_name}}} # default'
|
||||
@@ -113,6 +119,7 @@ class DockerKeyValueParser(KeyValueParser):
|
||||
"""Return a dictionary of key names and value parsers."""
|
||||
return dict(
|
||||
python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default),
|
||||
powershell=PowerShellParser(),
|
||||
seccomp=ChoicesParser(SECCOMP_CHOICES),
|
||||
cgroup=EnumValueChoicesParser(CGroupVersion),
|
||||
audit=EnumValueChoicesParser(AuditMode),
|
||||
@@ -128,6 +135,7 @@ class DockerKeyValueParser(KeyValueParser):
|
||||
|
||||
state.sections[f'{"controller" if self.controller else "target"} {section_name} (comma separated):'] = '\n'.join([
|
||||
f' python={python_parser.document(state)}',
|
||||
f' powershell={PowerShellParser().document(state)}',
|
||||
f' seccomp={ChoicesParser(SECCOMP_CHOICES).document(state)}',
|
||||
f' cgroup={EnumValueChoicesParser(CGroupVersion).document(state)}',
|
||||
f' audit={EnumValueChoicesParser(AuditMode).document(state)}',
|
||||
@@ -153,6 +161,7 @@ class PosixRemoteKeyValueParser(KeyValueParser):
|
||||
provider=ChoicesParser(REMOTE_PROVIDERS),
|
||||
arch=ChoicesParser(REMOTE_ARCHITECTURES),
|
||||
python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default),
|
||||
powershell=PowerShellParser(),
|
||||
)
|
||||
|
||||
def document(self, state: DocumentationState) -> t.Optional[str]:
|
||||
@@ -166,6 +175,7 @@ class PosixRemoteKeyValueParser(KeyValueParser):
|
||||
f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}',
|
||||
f' arch={ChoicesParser(REMOTE_ARCHITECTURES).document(state)}',
|
||||
f' python={python_parser.document(state)}',
|
||||
f' powershell={PowerShellParser().document(state)}',
|
||||
])
|
||||
|
||||
return f'{{{section_name}}}'
|
||||
@@ -180,6 +190,7 @@ class WindowsRemoteKeyValueParser(KeyValueParser):
|
||||
provider=ChoicesParser(REMOTE_PROVIDERS),
|
||||
arch=ChoicesParser(REMOTE_ARCHITECTURES),
|
||||
connection=ChoicesParser(WINDOWS_CONNECTIONS),
|
||||
powershell=PowerShellParser(windows=True),
|
||||
)
|
||||
|
||||
def document(self, state: DocumentationState) -> t.Optional[str]:
|
||||
@@ -190,6 +201,7 @@ class WindowsRemoteKeyValueParser(KeyValueParser):
|
||||
f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}',
|
||||
f' arch={ChoicesParser(REMOTE_ARCHITECTURES).document(state)}',
|
||||
f' connection={ChoicesParser(WINDOWS_CONNECTIONS).document(state)}',
|
||||
f' powershell={PowerShellParser(windows=True).document(state)}',
|
||||
])
|
||||
|
||||
return f'{{{section_name}}}'
|
||||
@@ -228,6 +240,7 @@ class PosixSshKeyValueParser(KeyValueParser):
|
||||
"""Return a dictionary of key names and value parsers."""
|
||||
return dict(
|
||||
python=PythonParser(versions=list(SUPPORTED_PYTHON_VERSIONS), allow_venv=False, allow_default=False),
|
||||
powershell=PowerShellPathParser(),
|
||||
)
|
||||
|
||||
def document(self, state: DocumentationState) -> t.Optional[str]:
|
||||
@@ -238,6 +251,7 @@ class PosixSshKeyValueParser(KeyValueParser):
|
||||
|
||||
state.sections[f'target {section_name} (comma separated):'] = '\n'.join([
|
||||
f' python={python_parser.document(state)}',
|
||||
f' powershell={PowerShellPathParser().document(state)}',
|
||||
])
|
||||
|
||||
return f'{{{section_name}}}'
|
||||
|
||||
@@ -9,6 +9,11 @@ from ...host_configs import (
|
||||
NativePythonConfig,
|
||||
PythonConfig,
|
||||
VirtualPythonConfig,
|
||||
PowerShellConfig,
|
||||
)
|
||||
|
||||
from ...util import (
|
||||
get_supported_powershell_versions,
|
||||
)
|
||||
|
||||
from ..argparsing.parsers import (
|
||||
@@ -135,6 +140,40 @@ class PythonParser(Parser):
|
||||
return docs
|
||||
|
||||
|
||||
class PowerShellParser(ChoicesParser):
|
||||
"""Argument parser for a PowerShell version."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
windows: bool = False,
|
||||
) -> None:
|
||||
versions = get_supported_powershell_versions()
|
||||
|
||||
if windows:
|
||||
versions.insert(0, '5.1') # pre-installed version on Windows
|
||||
|
||||
super().__init__(versions)
|
||||
|
||||
def parse(self, state: ParserState) -> t.Any:
|
||||
return PowerShellConfig(
|
||||
version=super().parse(state),
|
||||
)
|
||||
|
||||
|
||||
class PowerShellPathParser(AbsolutePathParser):
|
||||
"""Argument parser for a PowerShell path."""
|
||||
|
||||
def parse(self, state: ParserState) -> t.Any:
|
||||
return PowerShellConfig(
|
||||
path=super().parse(state),
|
||||
)
|
||||
|
||||
def document(self, state: DocumentationState) -> t.Optional[str]:
|
||||
"""Generate and return documentation for this parser."""
|
||||
return '/path/to/pwsh'
|
||||
|
||||
|
||||
class PlatformParser(ChoicesParser):
|
||||
"""Composite argument parser for "{platform}/{version}" formatted choices."""
|
||||
|
||||
|
||||
@@ -21,12 +21,14 @@ from ...util import (
|
||||
display,
|
||||
ApplicationError,
|
||||
raw_command,
|
||||
common_environment,
|
||||
)
|
||||
|
||||
from ...util_common import (
|
||||
ResultType,
|
||||
write_json_file,
|
||||
write_json_test_results,
|
||||
get_powershell_injector_env,
|
||||
)
|
||||
|
||||
from ...executor import (
|
||||
@@ -197,10 +199,13 @@ def _command_coverage_combine_powershell(args: CoverageCombineConfig) -> list[st
|
||||
coverage_files = get_powershell_coverage_files()
|
||||
|
||||
def _default_stub_value(source_paths: list[str]) -> dict[str, dict[int, int]]:
|
||||
env = common_environment()
|
||||
env.update(get_powershell_injector_env(args.controller_powershell, env))
|
||||
|
||||
cmd = ['pwsh', os.path.join(ANSIBLE_TEST_TOOLS_ROOT, 'coverage_stub.ps1')]
|
||||
cmd.extend(source_paths)
|
||||
|
||||
stubs = json.loads(raw_command(cmd, capture=True)[0])
|
||||
stubs = json.loads(raw_command(cmd, env=env, capture=True)[0])
|
||||
|
||||
return dict((d['Path'], dict((line, 0) for line in d['Lines'])) for d in stubs)
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ from ...util_common import (
|
||||
run_command,
|
||||
write_json_test_results,
|
||||
check_pyyaml,
|
||||
get_powershell_injector_env,
|
||||
)
|
||||
|
||||
from ...coverage_util import (
|
||||
@@ -677,6 +678,7 @@ def command_integration_script(
|
||||
|
||||
test_env.update_environment(env)
|
||||
env.update(coverage_manager.get_environment(target.name, target.aliases))
|
||||
env.update(get_powershell_injector_env(host_state.controller_profile.powershell, env))
|
||||
cover_python(args, host_state.controller_profile.python, cmd, target.name, env, cwd=cwd, capture=False)
|
||||
|
||||
|
||||
@@ -797,6 +799,7 @@ def command_integration_role(
|
||||
|
||||
test_env.update_environment(env)
|
||||
env.update(coverage_manager.get_environment(target.name, target.aliases))
|
||||
env.update(get_powershell_injector_env(host_state.controller_profile.powershell, env))
|
||||
cover_python(args, host_state.controller_profile.python, cmd, target.name, env, cwd=cwd, capture=False)
|
||||
|
||||
|
||||
|
||||
@@ -29,10 +29,12 @@ from ...util import (
|
||||
SubprocessError,
|
||||
find_executable,
|
||||
ANSIBLE_TEST_DATA_ROOT,
|
||||
common_environment,
|
||||
)
|
||||
|
||||
from ...util_common import (
|
||||
run_command,
|
||||
get_powershell_injector_env,
|
||||
)
|
||||
|
||||
from ...config import (
|
||||
@@ -57,6 +59,9 @@ class PslintTest(SanityVersionNeutral):
|
||||
return [target for target in targets if os.path.splitext(target.path)[1] in ('.ps1', '.psm1', '.psd1')]
|
||||
|
||||
def test(self, args: SanityConfig, targets: SanityTargets) -> TestResult:
|
||||
env = common_environment()
|
||||
env.update(get_powershell_injector_env(args.controller_powershell, env))
|
||||
|
||||
settings = self.load_processor(args)
|
||||
|
||||
paths = [target.path for target in targets.include]
|
||||
@@ -75,7 +80,7 @@ class PslintTest(SanityVersionNeutral):
|
||||
|
||||
for cmd in cmds:
|
||||
try:
|
||||
stdout, stderr = run_command(args, cmd, capture=True)
|
||||
stdout, stderr = run_command(args, cmd, env=env, capture=True)
|
||||
status = 0
|
||||
except SubprocessError as ex:
|
||||
stdout = ex.stdout
|
||||
|
||||
@@ -42,6 +42,7 @@ from ...util_common import (
|
||||
process_scoped_temporary_directory,
|
||||
run_command,
|
||||
ResultType,
|
||||
get_powershell_injector_env,
|
||||
)
|
||||
|
||||
from ...ansible_util import (
|
||||
@@ -122,6 +123,7 @@ class ValidateModulesTest(SanitySingleVersion):
|
||||
|
||||
def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult:
|
||||
env = ansible_environment(args, color=False)
|
||||
env.update(get_powershell_injector_env(args.controller_powershell, env))
|
||||
|
||||
settings = self.load_processor(args)
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ from ...python_requirements import (
|
||||
)
|
||||
|
||||
from ...util_common import (
|
||||
get_injector_env,
|
||||
get_python_injector_env,
|
||||
get_powershell_injector_env,
|
||||
)
|
||||
|
||||
from ...delegation import (
|
||||
@@ -206,7 +207,8 @@ def get_environment_variables(
|
||||
|
||||
if isinstance(con, LocalConnection): # configure the controller environment
|
||||
env.update(ansible_environment(args))
|
||||
env.update(get_injector_env(target_profile.python, env))
|
||||
env.update(get_python_injector_env(target_profile.python, env))
|
||||
env.update(get_powershell_injector_env(target_profile.powershell, env))
|
||||
env.update(ANSIBLE_TEST_METADATA_PATH=os.path.abspath(args.metadata_path))
|
||||
|
||||
if isinstance(target_profile, DebuggableProfile):
|
||||
|
||||
@@ -17,6 +17,7 @@ from .util import (
|
||||
ANSIBLE_TEST_DATA_ROOT,
|
||||
cache,
|
||||
read_lines_without_comments,
|
||||
get_powershell_version_map,
|
||||
)
|
||||
|
||||
from .data import (
|
||||
@@ -71,16 +72,29 @@ class PosixCompletionConfig(CompletionConfig, metaclass=abc.ABCMeta):
|
||||
def supported_pythons(self) -> list[str]:
|
||||
"""Return a list of the supported Python versions."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def supported_powershells(self) -> list[str]:
|
||||
"""Return a list of the supported PowerShell versions."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_python_path(self, version: str) -> str:
|
||||
"""Return the path of the requested Python version."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_powershell_path(self, version: str | None) -> str | None:
|
||||
"""Return the path of the requested PowerShell version, or None if PowerShell is not available."""
|
||||
|
||||
def get_default_python(self, controller: bool) -> str:
|
||||
"""Return the default Python version for a controller or target as specified."""
|
||||
context_pythons = CONTROLLER_PYTHON_VERSIONS if controller else SUPPORTED_PYTHON_VERSIONS
|
||||
version = [python for python in self.supported_pythons if python in context_pythons][0]
|
||||
return version
|
||||
|
||||
def get_default_powershell(self) -> str | None:
|
||||
"""Return the default PowerShell version, or None if there is no default."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def controller_supported(self) -> bool:
|
||||
"""True if at least one Python version is provided which supports the controller, otherwise False."""
|
||||
@@ -106,6 +120,28 @@ class PythonCompletionConfig(PosixCompletionConfig, metaclass=abc.ABCMeta):
|
||||
return os.path.join(self.python_dir, f'python{version}')
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PowerShellCompletionConfig(PosixCompletionConfig, metaclass=abc.ABCMeta):
|
||||
"""Base class for completion configuration of PowerShell environments."""
|
||||
|
||||
powershell: str = ''
|
||||
powershell_dir: str = '/usr/local/bin'
|
||||
|
||||
@property
|
||||
def supported_powershells(self) -> list[str]:
|
||||
"""Return a list of the supported PowerShell versions."""
|
||||
versions = self.powershell.split(',') if self.powershell else []
|
||||
versions = [version for version in versions if version in get_powershell_version_map()]
|
||||
return versions
|
||||
|
||||
def get_powershell_path(self, version: str | None) -> str | None:
|
||||
"""Return the path of the requested PowerShell version, or None if PowerShell is not available."""
|
||||
if not version:
|
||||
return None
|
||||
|
||||
return os.path.join(self.powershell_dir, f'pwsh{version}')
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RemoteCompletionConfig(CompletionConfig):
|
||||
"""Base class for completion configuration of remote environments provisioned through Ansible Core CI."""
|
||||
@@ -150,7 +186,7 @@ class InventoryCompletionConfig(CompletionConfig):
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PosixSshCompletionConfig(PythonCompletionConfig):
|
||||
class PosixSshCompletionConfig(PythonCompletionConfig, PowerShellCompletionConfig):
|
||||
"""Configuration for a POSIX host reachable over SSH."""
|
||||
|
||||
def __init__(self, user: str, host: str) -> None:
|
||||
@@ -166,7 +202,7 @@ class PosixSshCompletionConfig(PythonCompletionConfig):
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class DockerCompletionConfig(PythonCompletionConfig):
|
||||
class DockerCompletionConfig(PythonCompletionConfig, PowerShellCompletionConfig):
|
||||
"""Configuration for Docker containers."""
|
||||
|
||||
image: str = ''
|
||||
@@ -196,6 +232,10 @@ class DockerCompletionConfig(PythonCompletionConfig):
|
||||
except ValueError:
|
||||
raise ValueError(f'Docker completion entry "{self.name}" has an invalid value "{self.cgroup}" for the "cgroup" setting.') from None
|
||||
|
||||
def get_default_powershell(self) -> str | None:
|
||||
"""Return the default PowerShell version, or None if there is no default."""
|
||||
return next(iter(self.supported_powershells), None)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.image:
|
||||
raise Exception(f'Docker completion entry "{self.name}" must provide an "image" setting.')
|
||||
@@ -222,12 +262,16 @@ class NetworkRemoteCompletionConfig(RemoteCompletionConfig):
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PosixRemoteCompletionConfig(RemoteCompletionConfig, PythonCompletionConfig):
|
||||
class PosixRemoteCompletionConfig(RemoteCompletionConfig, PythonCompletionConfig, PowerShellCompletionConfig):
|
||||
"""Configuration for remote POSIX platforms."""
|
||||
|
||||
become: t.Optional[str] = None
|
||||
placeholder: bool = False
|
||||
|
||||
def get_default_powershell(self) -> str | None:
|
||||
"""Return the default PowerShell version, or None if there is no default."""
|
||||
return next(iter(self.supported_powershells), None)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.placeholder:
|
||||
super().__post_init__()
|
||||
|
||||
@@ -36,6 +36,7 @@ from .host_configs import (
|
||||
OriginConfig,
|
||||
PythonConfig,
|
||||
VirtualPythonConfig,
|
||||
PowerShellConfig,
|
||||
)
|
||||
|
||||
|
||||
@@ -91,6 +92,14 @@ class EnvironmentConfig(CommonConfig):
|
||||
Only available after delegation has been performed or skipped (if delegation is not required).
|
||||
"""
|
||||
|
||||
# Set by check_controller_powershell once HostState has been created by prepare_profiles.
|
||||
# This is here for convenience, to avoid needing to pass HostState to some functions which already have access to EnvironmentConfig.
|
||||
self.controller_powershell: PowerShellConfig | None = None
|
||||
"""
|
||||
The PowerShell interpreter used by the controller.
|
||||
Only available after delegation has been performed or skipped (if delegation is not required).
|
||||
"""
|
||||
|
||||
if self.host_path:
|
||||
self.delegate = False
|
||||
else:
|
||||
|
||||
@@ -19,6 +19,15 @@ TIMEOUT_PATH = '.ansible-test-timeout.json'
|
||||
CONTROLLER_MIN_PYTHON_VERSION = CONTROLLER_PYTHON_VERSIONS[0]
|
||||
SUPPORTED_PYTHON_VERSIONS = REMOTE_ONLY_PYTHON_VERSIONS + CONTROLLER_PYTHON_VERSIONS
|
||||
|
||||
SUPPORTED_POWERSHELL_VERSIONS = [
|
||||
'7.6.0-rc.1',
|
||||
]
|
||||
"""
|
||||
PowerShell versions supported by ansible-test.
|
||||
Full versions must be specified since they are used to install specific releases.
|
||||
Only one entry for each {major}.{minor} version is supported.
|
||||
"""
|
||||
|
||||
REMOTE_PROVIDERS = [
|
||||
'default',
|
||||
'aws',
|
||||
|
||||
@@ -43,6 +43,8 @@ from .util import (
|
||||
str_to_version,
|
||||
version_to_str,
|
||||
Architecture,
|
||||
find_executable,
|
||||
get_supported_powershell_versions,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +68,15 @@ class OriginCompletionConfig(PosixCompletionConfig):
|
||||
version = find_python(version)
|
||||
return version
|
||||
|
||||
@property
|
||||
def supported_powershells(self) -> list[str]:
|
||||
"""Return a list of the supported PowerShell versions."""
|
||||
return get_supported_powershell_versions()
|
||||
|
||||
def get_powershell_path(self, version: str | None) -> str | None:
|
||||
"""Return the path of the requested PowerShell version, or None if PowerShell is not available."""
|
||||
return find_executable(f'pwsh{version or ""}', required=bool(version))
|
||||
|
||||
@property
|
||||
def is_default(self) -> bool:
|
||||
"""True if the completion entry is only used for defaults, otherwise False."""
|
||||
@@ -179,11 +190,33 @@ class VirtualPythonConfig(PythonConfig):
|
||||
return True
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PowerShellConfig:
|
||||
"""Configuration for PowerShell."""
|
||||
|
||||
version: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
def apply_defaults(self, defaults: PosixCompletionConfig) -> None:
|
||||
"""Apply default settings."""
|
||||
if self.version in (None, 'default'):
|
||||
self.version = defaults.get_default_powershell()
|
||||
|
||||
if self.path:
|
||||
if self.path.endswith('/'):
|
||||
self.path = os.path.join(self.path, f'pwsh{self.version or ""}')
|
||||
|
||||
# FUTURE: If the host is origin, the pwsh path could be validated here.
|
||||
else:
|
||||
self.path = defaults.get_powershell_path(self.version)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PosixConfig(HostConfig, metaclass=abc.ABCMeta):
|
||||
"""Base class for POSIX host configuration."""
|
||||
|
||||
python: t.Optional[PythonConfig] = None
|
||||
powershell: PowerShellConfig | None = None
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
@@ -203,6 +236,9 @@ class PosixConfig(HostConfig, metaclass=abc.ABCMeta):
|
||||
self.python = self.python or NativePythonConfig()
|
||||
self.python.apply_defaults(context, defaults)
|
||||
|
||||
self.powershell = self.powershell or PowerShellConfig()
|
||||
self.powershell.apply_defaults(defaults)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ControllerHostConfig(PosixConfig, metaclass=abc.ABCMeta):
|
||||
@@ -401,6 +437,7 @@ class WindowsRemoteConfig(RemoteConfig, WindowsConfig):
|
||||
"""Configuration for a remote Windows host."""
|
||||
|
||||
connection: t.Optional[str] = None
|
||||
powershell: PowerShellConfig | None = None
|
||||
|
||||
def get_defaults(self, context: HostContext) -> WindowsRemoteCompletionConfig:
|
||||
"""Return the default settings."""
|
||||
@@ -492,6 +529,10 @@ class ControllerConfig(PosixConfig):
|
||||
# The user did not specify a target Python and supported Pythons are unknown, so use the controller Python specified by the user instead.
|
||||
self.python = context.controller_config.python
|
||||
|
||||
if not self.powershell and not defaults.supported_powershells:
|
||||
# The user did not specify a target PowerShell and supported versions are unknown, so use the controller version specified by the user instead.
|
||||
self.powershell = context.controller_config.powershell
|
||||
|
||||
super().apply_defaults(context, defaults)
|
||||
|
||||
@property
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import base64
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
@@ -40,6 +41,7 @@ from .host_configs import (
|
||||
VirtualPythonConfig,
|
||||
WindowsInventoryConfig,
|
||||
WindowsRemoteConfig,
|
||||
PowerShellConfig,
|
||||
)
|
||||
|
||||
from .core_ci import (
|
||||
@@ -63,6 +65,8 @@ from .util import (
|
||||
ANSIBLE_SOURCE_ROOT,
|
||||
ANSIBLE_LIB_ROOT,
|
||||
ANSIBLE_TEST_ROOT,
|
||||
Architecture,
|
||||
get_powershell_version_map,
|
||||
)
|
||||
|
||||
from .util_common import (
|
||||
@@ -474,6 +478,45 @@ class PosixProfile[TPosixConfig: PosixConfig](HostProfile[TPosixConfig], metacla
|
||||
|
||||
return python
|
||||
|
||||
@property
|
||||
def powershell(self) -> PowerShellConfig:
|
||||
"""
|
||||
The PowerShell to use for this profile.
|
||||
"""
|
||||
powershell = self.state.get('powershell')
|
||||
|
||||
if not powershell:
|
||||
powershell = self.config.powershell
|
||||
|
||||
self.state['powershell'] = powershell
|
||||
|
||||
return powershell
|
||||
|
||||
def get_python_interpreters(self) -> dict[str, str]:
|
||||
"""
|
||||
Get a mapping of Python interpreter versions and paths to use.
|
||||
A target uses a single Python version, but a controller may include additional versions for targets running on the controller.
|
||||
"""
|
||||
python_interpreters = {self.python.version: self.python.path}
|
||||
python_interpreters.update({target.python.version: target.python.path for target in self.targets if isinstance(target, ControllerConfig)})
|
||||
python_interpreters = {version: python_interpreters[version] for version in sorted_versions(list(python_interpreters))}
|
||||
|
||||
return python_interpreters
|
||||
|
||||
def get_powershell_versions(self) -> list[str]:
|
||||
"""
|
||||
Get a list of PowerShell versions to use.
|
||||
A target uses a single PowerShell version, but a controller may include additional versions for targets running on the controller.
|
||||
"""
|
||||
powershell_version_map = get_powershell_version_map()
|
||||
|
||||
powershell_versions = [self.powershell.version] if self.powershell.version else []
|
||||
powershell_versions.extend(target.powershell.version for target in self.targets if isinstance(target, ControllerConfig) and target.powershell.version)
|
||||
powershell_versions = sorted_versions(list(set(powershell_versions)))
|
||||
powershell_versions = [powershell_version_map[version] for version in powershell_versions]
|
||||
|
||||
return powershell_versions
|
||||
|
||||
|
||||
class ControllerHostProfile[T: ControllerHostConfig](PosixProfile[T], DebuggableProfile[T], metaclass=abc.ABCMeta):
|
||||
"""Base class for profiles usable as a controller."""
|
||||
@@ -601,6 +644,7 @@ class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[Con
|
||||
user='root',
|
||||
identity_file=SshKey(self.args).key,
|
||||
python_interpreter=self.args.controller_python.path,
|
||||
powershell_interpreter=self.args.controller_powershell.path,
|
||||
)
|
||||
|
||||
return [SshConnection(self.args, settings)]
|
||||
@@ -1160,7 +1204,8 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
|
||||
"""Perform out-of-band setup before delegation."""
|
||||
bootstrapper = BootstrapDocker(
|
||||
controller=self.controller,
|
||||
python_interpreters={self.python.version: self.python.path},
|
||||
python_interpreters=self.get_python_interpreters(),
|
||||
powershell_versions=self.get_powershell_versions(),
|
||||
ssh_key=SshKey(self.args),
|
||||
)
|
||||
|
||||
@@ -1245,6 +1290,7 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
|
||||
port=port,
|
||||
identity_file=SshKey(self.args).key,
|
||||
python_interpreter=self.python.path,
|
||||
powershell_interpreter=self.powershell.path,
|
||||
# CentOS 6 uses OpenSSH 5.3, making it incompatible with the default configuration of OpenSSH 8.8 and later clients.
|
||||
# Since only CentOS 6 is affected, and it is only supported by ansible-core 2.12, support for RSA SHA-1 is simply hard-coded here.
|
||||
# A substring is used to allow custom containers to work, not just the one provided with ansible-test.
|
||||
@@ -1439,11 +1485,6 @@ class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile
|
||||
|
||||
def configure(self) -> None:
|
||||
"""Perform in-band configuration. Executed before delegation for the controller and after delegation for targets."""
|
||||
# a target uses a single python version, but a controller may include additional versions for targets running on the controller
|
||||
python_interpreters = {self.python.version: self.python.path}
|
||||
python_interpreters.update({target.python.version: target.python.path for target in self.targets if isinstance(target, ControllerConfig)})
|
||||
python_interpreters = {version: python_interpreters[version] for version in sorted_versions(list(python_interpreters.keys()))}
|
||||
|
||||
core_ci = self.wait_for_instance()
|
||||
pwd = self.wait_until_ready()
|
||||
|
||||
@@ -1453,7 +1494,8 @@ class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile
|
||||
controller=self.controller,
|
||||
platform=self.config.platform,
|
||||
platform_version=self.config.version,
|
||||
python_interpreters=python_interpreters,
|
||||
python_interpreters=self.get_python_interpreters(),
|
||||
powershell_versions=self.get_powershell_versions(),
|
||||
ssh_key=core_ci.ssh_key,
|
||||
)
|
||||
|
||||
@@ -1474,6 +1516,7 @@ class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile
|
||||
port=core_ci.connection.port,
|
||||
identity_file=core_ci.ssh_key.key,
|
||||
python_interpreter=self.python.path,
|
||||
powershell_interpreter=self.powershell.path,
|
||||
)
|
||||
|
||||
if settings.user == 'root':
|
||||
@@ -1555,6 +1598,7 @@ class PosixSshProfile(SshTargetHostProfile[PosixSshConfig], PosixProfile[PosixSs
|
||||
port=self.config.port,
|
||||
identity_file=SshKey(self.args).key,
|
||||
python_interpreter=self.python.path,
|
||||
powershell_interpreter=self.powershell.path,
|
||||
)
|
||||
|
||||
return [SshConnection(self.args, settings)]
|
||||
@@ -1593,10 +1637,64 @@ class WindowsInventoryProfile(SshTargetHostProfile[WindowsInventoryConfig]):
|
||||
class WindowsRemoteProfile(RemoteProfile[WindowsRemoteConfig]):
|
||||
"""Host profile for a Windows remote instance."""
|
||||
|
||||
_ARCHES = {
|
||||
Architecture.X86_64: 'x64',
|
||||
Architecture.AARCH64: 'arm64',
|
||||
}
|
||||
"""Mapping of ansible-test architecture to PowerShell release architecture label."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.pwsh_interpreter_path: str | None = None
|
||||
|
||||
def wait(self) -> None:
|
||||
"""Wait for the instance to be ready. Executed before delegation for the controller and after delegation for targets."""
|
||||
self.wait_until_ready()
|
||||
|
||||
def configure(self) -> None:
|
||||
"""Perform in-band configuration. Executed before delegation for the controller and after delegation for targets."""
|
||||
if not self.config.powershell: # nothing to install
|
||||
return
|
||||
|
||||
if self.config.powershell.version == '5.1': # built-in version, nothing to install
|
||||
return
|
||||
|
||||
self.wait_for_instance()
|
||||
self.wait_until_ready()
|
||||
|
||||
full_version = get_powershell_version_map()[self.config.powershell.version]
|
||||
arch = self._ARCHES[self.config.arch]
|
||||
download_uri = f'https://github.com/PowerShell/PowerShell/releases/download/v{full_version}/PowerShell-{full_version}-win-{arch}.zip'
|
||||
setup_path = pathlib.Path(ANSIBLE_TEST_TARGET_ROOT) / 'setup'
|
||||
bootstrap_script_path = setup_path / 'bootstrap.ps1'
|
||||
entrypoint_script_path = setup_path / 'entrypoint.ps1'
|
||||
|
||||
setup_manifest = dict(
|
||||
script=bootstrap_script_path.read_text(),
|
||||
path=str(bootstrap_script_path.resolve()),
|
||||
params=dict(
|
||||
PowerShellVersion=self.config.powershell.version,
|
||||
PowerShellDownloadUri=download_uri,
|
||||
),
|
||||
)
|
||||
|
||||
encoded_manifest = base64.b64encode(json.dumps(setup_manifest).encode()).decode()
|
||||
|
||||
# Passing a command through stdin requires newlines at the end of the input
|
||||
# so it sees it as a complete statement rather than ignoring an incomplete one.
|
||||
setup_entrypoint = entrypoint_script_path.read_text().replace('{{ MANIFEST }}', encoded_manifest).strip() + "\n\n"
|
||||
|
||||
ssh = self.get_controller_target_connections()[0]
|
||||
|
||||
stdout, dummy = ssh.run(
|
||||
command=['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '-'],
|
||||
data=setup_entrypoint,
|
||||
capture=True,
|
||||
)
|
||||
|
||||
self.pwsh_interpreter_path = stdout.strip()
|
||||
|
||||
def get_inventory_variables(self) -> dict[str, t.Optional[t.Union[str, int]]]:
|
||||
"""Return inventory variables for accessing this host."""
|
||||
core_ci = self.wait_for_instance()
|
||||
@@ -1613,6 +1711,9 @@ class WindowsRemoteProfile(RemoteProfile[WindowsRemoteConfig]):
|
||||
variables.update(ansible_connection=self.config.connection.split('+')[0])
|
||||
variables.update(WINDOWS_CONNECTION_VARIABLES[self.config.connection])
|
||||
|
||||
if self.pwsh_interpreter_path:
|
||||
variables.update(ansible_pwsh_interpreter=self.pwsh_interpreter_path)
|
||||
|
||||
if variables.pop('use_password'):
|
||||
variables.update(ansible_password=connection.password)
|
||||
|
||||
|
||||
@@ -68,14 +68,19 @@ def get_common_variables(target_profile: HostProfile, controller: bool = False)
|
||||
|
||||
def create_controller_inventory(args: EnvironmentConfig, path: str, controller_host: ControllerHostProfile) -> None:
|
||||
"""Create and return inventory for use in controller-only integration tests."""
|
||||
testhost: dict[str, str | int | None] = get_common_variables(controller_host, controller=True) | dict(
|
||||
ansible_connection='local',
|
||||
ansible_pipelining='yes',
|
||||
ansible_python_interpreter=controller_host.python.path,
|
||||
ansible_pwsh_interpreter=controller_host.powershell.path,
|
||||
)
|
||||
|
||||
testhost = exclude_none_values(testhost)
|
||||
|
||||
inventory = Inventory(
|
||||
host_groups=dict(
|
||||
testgroup=dict(
|
||||
testhost=get_common_variables(controller_host, controller=True) | dict(
|
||||
ansible_connection='local',
|
||||
ansible_pipelining='yes',
|
||||
ansible_python_interpreter=controller_host.python.path,
|
||||
),
|
||||
testhost=testhost,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -159,17 +164,14 @@ def create_posix_inventory(args: EnvironmentConfig, path: str, target_hosts: lis
|
||||
|
||||
target_host = target_hosts[0]
|
||||
|
||||
testhost: dict[str, str | int | None] = get_common_variables(target_host)
|
||||
|
||||
if isinstance(target_host, ControllerProfile) and not needs_ssh:
|
||||
inventory = Inventory(
|
||||
host_groups=dict(
|
||||
testgroup=dict(
|
||||
testhost=get_common_variables(target_host) | dict(
|
||||
ansible_connection='local',
|
||||
ansible_pipelining='yes',
|
||||
ansible_python_interpreter=target_host.python.path,
|
||||
),
|
||||
),
|
||||
),
|
||||
testhost |= dict(
|
||||
ansible_connection='local',
|
||||
ansible_pipelining='yes',
|
||||
ansible_python_interpreter=target_host.python.path,
|
||||
ansible_pwsh_interpreter=target_host.powershell.path,
|
||||
)
|
||||
else:
|
||||
connections = target_host.get_controller_target_connections()
|
||||
@@ -179,10 +181,11 @@ def create_posix_inventory(args: EnvironmentConfig, path: str, target_hosts: lis
|
||||
|
||||
ssh = connections[0]
|
||||
|
||||
testhost: dict[str, t.Optional[t.Union[str, int]]] = get_common_variables(target_host) | dict(
|
||||
testhost |= dict(
|
||||
ansible_connection='ssh',
|
||||
ansible_pipelining='yes',
|
||||
ansible_python_interpreter=ssh.settings.python_interpreter,
|
||||
ansible_pwsh_interpreter=ssh.settings.powershell_interpreter,
|
||||
ansible_host=ssh.settings.host,
|
||||
ansible_port=ssh.settings.port,
|
||||
ansible_user=ssh.settings.user,
|
||||
@@ -196,14 +199,14 @@ def create_posix_inventory(args: EnvironmentConfig, path: str, target_hosts: lis
|
||||
ansible_become_method=ssh.become.method,
|
||||
)
|
||||
|
||||
testhost = exclude_none_values(testhost)
|
||||
testhost = exclude_none_values(testhost)
|
||||
|
||||
inventory = Inventory(
|
||||
host_groups=dict(
|
||||
testgroup=dict(
|
||||
testhost=testhost,
|
||||
),
|
||||
inventory = Inventory(
|
||||
host_groups=dict(
|
||||
testgroup=dict(
|
||||
testhost=testhost,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
inventory.write(args, path)
|
||||
|
||||
@@ -147,6 +147,7 @@ def prepare_profiles[TEnvironmentConfig: EnvironmentConfig](
|
||||
|
||||
if not args.delegate:
|
||||
check_controller_python(args, host_state)
|
||||
check_controller_powershell(args, host_state)
|
||||
|
||||
if requirements:
|
||||
requirements(host_state.controller_profile)
|
||||
@@ -184,6 +185,13 @@ def check_controller_python(args: EnvironmentConfig, host_state: HostState) -> N
|
||||
args.controller_python = controller_python
|
||||
|
||||
|
||||
def check_controller_powershell(args: EnvironmentConfig, host_state: HostState) -> None:
|
||||
"""Check the running environment to make sure it is what we expected."""
|
||||
controller_powershell = host_state.controller_profile.powershell
|
||||
|
||||
args.controller_powershell = controller_powershell
|
||||
|
||||
|
||||
def cleanup_profiles(host_state: HostState) -> None:
|
||||
"""Cleanup provisioned hosts when exiting."""
|
||||
for profile in host_state.profiles:
|
||||
|
||||
@@ -39,6 +39,7 @@ class SshConnectionDetail:
|
||||
user: str
|
||||
identity_file: str
|
||||
python_interpreter: t.Optional[str] = None
|
||||
powershell_interpreter: str | None = None
|
||||
shell_type: t.Optional[str] = None
|
||||
enable_rsa_sha1: bool = False
|
||||
|
||||
@@ -285,6 +286,7 @@ def generate_ssh_inventory(ssh_connections: list[SshConnectionDetail]) -> str:
|
||||
ansible_connection='ssh',
|
||||
ansible_pipelining='yes',
|
||||
ansible_python_interpreter=ssh.python_interpreter,
|
||||
ansible_pwsh_interpreter=ssh.powershell_interpreter,
|
||||
ansible_shell_type=ssh.shell_type,
|
||||
ansible_ssh_extra_args=ssh_options_to_str(dict(UserKnownHostsFile='/dev/null', **ssh.options)), # avoid changing the test environment
|
||||
ansible_ssh_host_key_checking='no',
|
||||
|
||||
@@ -55,9 +55,11 @@ from .thread import (
|
||||
|
||||
from .constants import (
|
||||
SUPPORTED_PYTHON_VERSIONS,
|
||||
SUPPORTED_POWERSHELL_VERSIONS,
|
||||
)
|
||||
|
||||
PYTHON_PATHS: dict[str, str] = {}
|
||||
POWERSHELL_PATHS: dict[str, str] = {}
|
||||
|
||||
COVERAGE_CONFIG_NAME = 'coveragerc'
|
||||
|
||||
@@ -1238,6 +1240,16 @@ def type_guard[C](sequence: c.Sequence[t.Any], guard_type: t.Type[C]) -> t.TypeG
|
||||
raise Exception(f'Sequence required to contain only {guard_type} includes: {", ".join(invalid_type_names)}')
|
||||
|
||||
|
||||
def get_powershell_version_map() -> dict[str, str]:
|
||||
"""Return a mapping of PowerShell {major}.{minor} version to full PowerShell version."""
|
||||
return {version_to_str(tuple((int(v) for v in version.split('.')[:2]))): version for version in SUPPORTED_POWERSHELL_VERSIONS}
|
||||
|
||||
|
||||
def get_supported_powershell_versions() -> list[str]:
|
||||
"""Return a list of supported PowerShell versions."""
|
||||
return list(get_powershell_version_map())
|
||||
|
||||
|
||||
display = Display() # pylint: disable=locally-disabled, invalid-name
|
||||
|
||||
_enable_vendoring()
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import collections.abc as c
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -31,6 +32,7 @@ from .util import (
|
||||
MODE_FILE,
|
||||
OutputStream,
|
||||
PYTHON_PATHS,
|
||||
POWERSHELL_PATHS,
|
||||
raw_command,
|
||||
ANSIBLE_TEST_DATA_ROOT,
|
||||
ANSIBLE_TEST_TARGET_ROOT,
|
||||
@@ -59,6 +61,7 @@ from .provider.layout import (
|
||||
from .host_configs import (
|
||||
PythonConfig,
|
||||
VirtualPythonConfig,
|
||||
PowerShellConfig,
|
||||
)
|
||||
|
||||
CHECK_YAML_VERSIONS: dict[str, t.Any] = {}
|
||||
@@ -297,7 +300,7 @@ def write_text_test_results(category: ResultType, name: str, content: str) -> No
|
||||
|
||||
|
||||
@cache
|
||||
def get_injector_path() -> str:
|
||||
def get_python_injector_path() -> str:
|
||||
"""Return the path to a directory which contains a `python.py` executable and associated injector scripts."""
|
||||
injector_path = tempfile.mkdtemp(prefix='ansible-test-', suffix='-injector', dir='/tmp')
|
||||
|
||||
@@ -365,18 +368,28 @@ def set_shebang(script: str, executable: str) -> str:
|
||||
|
||||
def get_python_path(interpreter: str) -> str:
|
||||
"""Return the path to a directory which contains a `python` executable that runs the specified interpreter."""
|
||||
python_path = PYTHON_PATHS.get(interpreter)
|
||||
return get_injection_wrapper(interpreter, 'python', PYTHON_PATHS)
|
||||
|
||||
if python_path:
|
||||
return python_path
|
||||
|
||||
prefix = 'python-'
|
||||
def get_powershell_path(interpreter: str) -> str:
|
||||
"""Return the path to a directory which contains a `pwsh` executable that runs the specified interpreter."""
|
||||
return get_injection_wrapper(interpreter, 'pwsh', POWERSHELL_PATHS)
|
||||
|
||||
|
||||
def get_injection_wrapper(interpreter: str, name: str, cached_paths: dict[str, str]) -> str:
|
||||
"""Return the path to a directory which contains the named executable that runs the specified interpreter."""
|
||||
injected_path = cached_paths.get(interpreter)
|
||||
|
||||
if injected_path:
|
||||
return injected_path
|
||||
|
||||
prefix = f'{name}-'
|
||||
suffix = '-ansible'
|
||||
|
||||
root_temp_dir = '/tmp'
|
||||
|
||||
python_path = tempfile.mkdtemp(prefix=prefix, suffix=suffix, dir=root_temp_dir)
|
||||
injected_interpreter = os.path.join(python_path, 'python')
|
||||
injected_path = tempfile.mkdtemp(prefix=prefix, suffix=suffix, dir=root_temp_dir)
|
||||
injected_interpreter = os.path.join(injected_path, name)
|
||||
|
||||
# A symlink is faster than the execv wrapper, but isn't guaranteed to provide the correct result.
|
||||
# There are several scenarios known not to work with symlinks:
|
||||
@@ -390,14 +403,14 @@ def get_python_path(interpreter: str) -> str:
|
||||
|
||||
create_interpreter_wrapper(interpreter, injected_interpreter)
|
||||
|
||||
verified_chmod(python_path, MODE_DIRECTORY)
|
||||
verified_chmod(injected_path, MODE_DIRECTORY)
|
||||
|
||||
if not PYTHON_PATHS:
|
||||
ExitHandler.register(cleanup_python_paths)
|
||||
if not cached_paths:
|
||||
ExitHandler.register(functools.partial(cleanup_injector_paths, cached_paths))
|
||||
|
||||
PYTHON_PATHS[interpreter] = python_path
|
||||
cached_paths[interpreter] = injected_path
|
||||
|
||||
return python_path
|
||||
return injected_path
|
||||
|
||||
|
||||
def create_temp_dir(prefix: t.Optional[str] = None, suffix: t.Optional[str] = None, base_dir: t.Optional[str] = None) -> str:
|
||||
@@ -408,33 +421,33 @@ def create_temp_dir(prefix: t.Optional[str] = None, suffix: t.Optional[str] = No
|
||||
|
||||
|
||||
def create_interpreter_wrapper(interpreter: str, injected_interpreter: str) -> None:
|
||||
"""Create a wrapper for the given Python interpreter at the specified path."""
|
||||
"""Create a wrapper for the given interpreter at the specified path."""
|
||||
# sys.executable is used for the shebang to guarantee it is a binary instead of a script
|
||||
# injected_interpreter could be a script from the system or our own wrapper created for the --venv option
|
||||
shebang_interpreter = sys.executable
|
||||
|
||||
code = textwrap.dedent("""
|
||||
#!%s
|
||||
code = textwrap.dedent(f"""
|
||||
#!{shebang_interpreter}
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from os import execv
|
||||
from sys import argv
|
||||
|
||||
python = '%s'
|
||||
interpreter = {interpreter!r}
|
||||
|
||||
execv(python, [python] + argv[1:])
|
||||
""" % (shebang_interpreter, interpreter)).lstrip()
|
||||
execv(interpreter, [interpreter] + argv[1:])
|
||||
""").lstrip()
|
||||
|
||||
write_text_file(injected_interpreter, code)
|
||||
|
||||
verified_chmod(injected_interpreter, MODE_FILE_EXECUTE)
|
||||
|
||||
|
||||
def cleanup_python_paths() -> None:
|
||||
"""Clean up all temporary python directories."""
|
||||
for path in sorted(PYTHON_PATHS.values()):
|
||||
display.info('Cleaning up temporary python directory: %s' % path, verbosity=2)
|
||||
def cleanup_injector_paths(cached_paths: dict[str, str]) -> None:
|
||||
"""Clean up all temporary injector directories."""
|
||||
for path in sorted(cached_paths.values()):
|
||||
display.info(f'Cleaning up temporary injector directory: {path}', verbosity=2)
|
||||
remove_tree(path)
|
||||
|
||||
|
||||
@@ -454,18 +467,18 @@ def intercept_python(
|
||||
Otherwise, a temporary directory will be created to ensure the correct Python can be found in PATH.
|
||||
"""
|
||||
cmd = list(cmd)
|
||||
env = get_injector_env(python, env)
|
||||
env = get_python_injector_env(python, env)
|
||||
|
||||
return run_command(args, cmd, capture=capture, env=env, data=data, cwd=cwd, always=always)
|
||||
|
||||
|
||||
def get_injector_env(
|
||||
def get_python_injector_env(
|
||||
python: PythonConfig,
|
||||
env: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Get the environment variables needed to inject the given Python interpreter into the environment."""
|
||||
env = env.copy()
|
||||
inject_path = get_injector_path()
|
||||
inject_path = get_python_injector_path()
|
||||
|
||||
# make sure scripts (including injector.py) find the correct Python interpreter
|
||||
if isinstance(python, VirtualPythonConfig):
|
||||
@@ -480,6 +493,24 @@ def get_injector_env(
|
||||
return env
|
||||
|
||||
|
||||
def get_powershell_injector_env(
|
||||
powershell: PowerShellConfig | None,
|
||||
env: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Get the environment variables needed to inject the given PowerShell interpreter into the environment."""
|
||||
env = env.copy()
|
||||
|
||||
if not powershell or not powershell.path:
|
||||
return env
|
||||
|
||||
powershell_path = get_powershell_path(powershell.path)
|
||||
|
||||
env['PATH'] = os.path.pathsep.join([powershell_path, env['PATH']])
|
||||
env['ANSIBLE_TEST_POWERSHELL_INTERPRETER'] = powershell.path
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def run_command(
|
||||
args: CommonConfig,
|
||||
cmd: c.Iterable[str],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using namespace System.IO
|
||||
using namespace System.Text
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$PowerShellVersion,
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$PowerShellDownloadUri
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$installDir = [Path]::Combine($env:ProgramFiles, "Ansible", "PowerShell", $PowerShellVersion)
|
||||
$pwshExe = [Path]::Combine($installDir, 'pwsh.exe')
|
||||
|
||||
if (Test-Path -LiteralPath $pwshExe) {
|
||||
return $pwshExe
|
||||
}
|
||||
|
||||
# We use the zip installation method to allow side-by-side installs without
|
||||
# affecting existing PowerShell installations.
|
||||
$zipFilename = "PowerShell-$PowerShellVersion.zip"
|
||||
$zipPath = [Path]::Combine([Path]::GetTempPath(), $zipFilename)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $zipPath)) {
|
||||
$attempts = 0
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $PowerShellDownloadUri -OutFile $zipPath -UseBasicParsing
|
||||
}
|
||||
|
||||
catch {
|
||||
$attempts++
|
||||
|
||||
if ($attempts -gt 5) {
|
||||
throw "Failed to download PowerShell from $PowerShellDownloadUri after $attempts attempts."
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 5
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $installDir)) {
|
||||
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Expand-Archive -LiteralPath $zipPath -DestinationPath $installDir -Force
|
||||
|
||||
$null = & $pwshExe -Command "exit"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "PowerShell installation verification failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
return $pwshExe
|
||||
@@ -93,6 +93,88 @@ install_pip() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_powershell() {
|
||||
if [ ! "${powershell_versions}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
for powershell_version in ${powershell_versions}; do
|
||||
install_powershell_version
|
||||
done
|
||||
}
|
||||
|
||||
install_powershell_version() {
|
||||
version="${powershell_version}"
|
||||
major_version="$(echo "${version}" | cut -f 1 -d .)"
|
||||
minor_version="$(echo "${version}" | cut -f 2 -d .)"
|
||||
major_minor_version="${major_version}.${minor_version}"
|
||||
install_dir="/opt/microsoft/powershell/${major_minor_version}"
|
||||
pwsh_installed_bin="${install_dir}/pwsh"
|
||||
pwsh_versioned_bin="/usr/local/bin/pwsh${major_minor_version}"
|
||||
|
||||
if [ -x "${pwsh_versioned_bin}" ]; then
|
||||
echo "Detected PowerShell ${major_minor_version} at: ${pwsh_versioned_bin}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Bootstrapping PowerShell ${major_minor_version} at: ${pwsh_versioned_bin}"
|
||||
|
||||
system="$("${python_interpreter}" -c 'import platform; print(platform.system());')"
|
||||
|
||||
case "${system}" in
|
||||
Linux)
|
||||
if ! "${python_interpreter}" -c "import os; os.confstr('CS_GNU_LIBC_VERSION');" 2>/dev/null; then
|
||||
echo "Only glibc-based Linux distributions are supported."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
system="linux"
|
||||
;;
|
||||
Darwin)
|
||||
system="osx"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported system: ${system}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
arch="$("${python_interpreter}" -c 'import platform; print(platform.machine());')"
|
||||
|
||||
case "${arch}" in
|
||||
x86_64)
|
||||
arch="x64"
|
||||
;;
|
||||
aarch64)
|
||||
arch="arm64"
|
||||
;;
|
||||
arm64)
|
||||
arch="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: ${arch}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
url="https://github.com/PowerShell/PowerShell/releases/download/v${version}/powershell-${version}-${system}-${arch}.tar.gz"
|
||||
tmp_file="/tmp/powershell-${major_minor_version}.tgz"
|
||||
|
||||
retry_init
|
||||
while true; do
|
||||
curl --silent --show-error --location "${url}" -o "${tmp_file}" \
|
||||
&& break
|
||||
retry_or_fail
|
||||
done
|
||||
|
||||
mkdir -p "${install_dir}"
|
||||
tar zxf "${tmp_file}" --no-same-owner --no-same-permissions -C "${install_dir}"
|
||||
rm "${tmp_file}"
|
||||
find "${install_dir}" -type f -exec chmod -x "{}" ";"
|
||||
chmod +x "${pwsh_installed_bin}"
|
||||
ln -s "${pwsh_installed_bin}" "${pwsh_versioned_bin}"
|
||||
}
|
||||
|
||||
optimize_dnf()
|
||||
{
|
||||
if ! grep ansible-test /etc/dnf/dnf.conf > /dev/null; then
|
||||
@@ -158,6 +240,7 @@ bootstrap_remote_fedora()
|
||||
acl
|
||||
gcc
|
||||
${py_pkg_prefix}-devel
|
||||
which
|
||||
"
|
||||
|
||||
if [ "${controller}" ]; then
|
||||
@@ -465,6 +548,8 @@ bootstrap()
|
||||
"docker") bootstrap_docker ;;
|
||||
"remote") bootstrap_remote ;;
|
||||
esac
|
||||
|
||||
install_powershell
|
||||
}
|
||||
|
||||
# These variables will be templated before sending the script to the host.
|
||||
@@ -477,5 +562,6 @@ python_interpreters=#{python_interpreters}
|
||||
ssh_key_type=#{ssh_key_type}
|
||||
ssh_private_key=#{ssh_private_key}
|
||||
ssh_public_key=#{ssh_public_key}
|
||||
powershell_versions=#{powershell_versions}
|
||||
|
||||
bootstrap
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
& {
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$codeJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{ MANIFEST }}'))
|
||||
$code = $codeJson | ConvertFrom-Json
|
||||
$splat = @{}
|
||||
|
||||
foreach ($obj in $code.params.PSObject.Properties) {
|
||||
$splat[$obj.Name] = $obj.Value
|
||||
}
|
||||
|
||||
$errors = @()
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseInput($code.script, $code.path, [ref]$null, [ref]$errors)
|
||||
|
||||
if ($errors) {
|
||||
throw "Failed to parse PowerShell script: $errors"
|
||||
}
|
||||
|
||||
$cmd = $ast.GetScriptBlock()
|
||||
|
||||
& $cmd @splat
|
||||
}
|
||||
Reference in New Issue
Block a user