Matt Clay 1 week ago committed by GitHub
commit fe2497f9af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -4,8 +4,8 @@
set -eux set -eux
# The third PATH entry is the injected bin directory created by ansible-test. # The PATH entry ending in "-bin" is the injected bin directory created by ansible-test.
bin_dir="$(python -c 'import os; print(os.environ["PATH"].split(":")[2])')" bin_dir="$(python -c 'import os; print([path for path in os.environ["PATH"].split(":") if path.endswith("-bin")][0])')"
while IFS= read -r name while IFS= read -r name
do do

@ -25,6 +25,8 @@ if ! command -V pwsh; then
exit 0 exit 0
fi fi
pwsh --version
# Use a PowerShell-only collection to verify that validate-modules does not load the collection loader multiple times. # Use a PowerShell-only collection to verify that validate-modules does not load the collection loader multiple times.
ansible-test sanity --test validate-modules --color --truncate 0 "${@}" ansible-test sanity --test validate-modules --color --truncate 0 "${@}"

@ -0,0 +1,3 @@
context/target
gather_facts/no
shippable/posix/group2

@ -0,0 +1,8 @@
- name: Get PowerShell version
command: pwsh --version
register: powershell_version
failed_when: false
- name: Show PowerShell Version
debug:
msg: "{{ powershell_version.stdout }}"

@ -1,6 +1,6 @@
base image=quay.io/ansible/base-test-container:v2.21-0 python=3.14,3.9,3.10,3.11,3.12,3.13 base image=quay.io/ansible/base-test-container:v2.21-0 python=3.14,3.9,3.10,3.11,3.12,3.13 powershell=7.5
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/default-test-container:v2.21-0 python=3.14,3.9,3.10,3.11,3.12,3.13 powershell=7.5 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 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 powershell=7.5 context=ansible-core
alpine322 image=quay.io/ansible/alpine-test-container:3.22-v2.20-1 python=3.12 cgroup=none audit=none alpine322 image=quay.io/ansible/alpine-test-container:3.22-v2.20-1 python=3.12 cgroup=none audit=none
fedora42 image=quay.io/ansible/fedora-test-container:42-v2.20-1 python=3.13 cgroup=v2-only fedora42 image=quay.io/ansible/fedora-test-container:42-v2.20-1 python=3.13 cgroup=v2-only
ubuntu2204 image=quay.io/ansible/ubuntu-test-container:22.04-v2.20-1 python=3.10 ubuntu2204 image=quay.io/ansible/ubuntu-test-container:22.04-v2.20-1 python=3.10

@ -30,7 +30,7 @@ from .util_common import (
create_temp_dir, create_temp_dir,
ResultType, ResultType,
intercept_python, intercept_python,
get_injector_path, get_python_injector_path,
) )
from .config import ( 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 # it only requires the injector for code coverage
# the correct python interpreter is already selected using the sys.executable used to invoke ansible # the correct python interpreter is already selected using the sys.executable used to invoke ansible
ansible.update( 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): if isinstance(args, PosixIntegrationConfig):

@ -43,6 +43,7 @@ from ..argparsing.parsers import (
from .value_parsers import ( from .value_parsers import (
PythonParser, PythonParser,
PowerShellParser,
) )
from .helpers import ( from .helpers import (
@ -61,6 +62,7 @@ class OriginKeyValueParser(KeyValueParser):
return dict( return dict(
python=PythonParser(versions=versions, allow_venv=True, allow_default=True), python=PythonParser(versions=versions, allow_venv=True, allow_default=True),
powershell=PowerShellParser(),
) )
def document(self, state: DocumentationState) -> t.Optional[str]: def document(self, state: DocumentationState) -> t.Optional[str]:
@ -87,6 +89,7 @@ class ControllerKeyValueParser(KeyValueParser):
return dict( return dict(
python=PythonParser(versions=versions, allow_venv=allow_venv, allow_default=allow_default), python=PythonParser(versions=versions, allow_venv=allow_venv, allow_default=allow_default),
powershell=PowerShellParser(),
) )
def document(self, state: DocumentationState) -> t.Optional[str]: def document(self, state: DocumentationState) -> t.Optional[str]:
@ -113,6 +116,7 @@ class DockerKeyValueParser(KeyValueParser):
"""Return a dictionary of key names and value parsers.""" """Return a dictionary of key names and value parsers."""
return dict( return dict(
python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default), python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default),
powershell=PowerShellParser(),
seccomp=ChoicesParser(SECCOMP_CHOICES), seccomp=ChoicesParser(SECCOMP_CHOICES),
cgroup=EnumValueChoicesParser(CGroupVersion), cgroup=EnumValueChoicesParser(CGroupVersion),
audit=EnumValueChoicesParser(AuditMode), audit=EnumValueChoicesParser(AuditMode),
@ -153,6 +157,7 @@ class PosixRemoteKeyValueParser(KeyValueParser):
provider=ChoicesParser(REMOTE_PROVIDERS), provider=ChoicesParser(REMOTE_PROVIDERS),
arch=ChoicesParser(REMOTE_ARCHITECTURES), arch=ChoicesParser(REMOTE_ARCHITECTURES),
python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default), python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default),
powershell=PowerShellParser(),
) )
def document(self, state: DocumentationState) -> t.Optional[str]: def document(self, state: DocumentationState) -> t.Optional[str]:
@ -228,6 +233,7 @@ class PosixSshKeyValueParser(KeyValueParser):
"""Return a dictionary of key names and value parsers.""" """Return a dictionary of key names and value parsers."""
return dict( return dict(
python=PythonParser(versions=list(SUPPORTED_PYTHON_VERSIONS), allow_venv=False, allow_default=False), python=PythonParser(versions=list(SUPPORTED_PYTHON_VERSIONS), allow_venv=False, allow_default=False),
powershell=PowerShellParser(),
) )
def document(self, state: DocumentationState) -> t.Optional[str]: def document(self, state: DocumentationState) -> t.Optional[str]:

@ -5,10 +5,15 @@ from __future__ import annotations
import collections.abc as c import collections.abc as c
import typing as t import typing as t
from ...constants import (
SUPPORTED_POWERSHELL_VERSIONS,
)
from ...host_configs import ( from ...host_configs import (
NativePythonConfig, NativePythonConfig,
PythonConfig, PythonConfig,
VirtualPythonConfig, VirtualPythonConfig,
PowerShellConfig,
) )
from ..argparsing.parsers import ( from ..argparsing.parsers import (
@ -135,6 +140,20 @@ class PythonParser(Parser):
return docs return docs
class PowerShellParser(ChoicesParser):
"""
Composite argument parser for PowerShell versions.
"""
def __init__(self) -> None:
super().__init__(SUPPORTED_POWERSHELL_VERSIONS)
def parse(self, state: ParserState) -> t.Any:
return PowerShellConfig(
version=super().parse(state),
)
class PlatformParser(ChoicesParser): class PlatformParser(ChoicesParser):
"""Composite argument parser for "{platform}/{version}" formatted choices.""" """Composite argument parser for "{platform}/{version}" formatted choices."""

@ -21,12 +21,14 @@ from ...util import (
display, display,
ApplicationError, ApplicationError,
raw_command, raw_command,
common_environment,
) )
from ...util_common import ( from ...util_common import (
ResultType, ResultType,
write_json_file, write_json_file,
write_json_test_results, write_json_test_results,
get_powershell_injector_env,
) )
from ...executor import ( from ...executor import (
@ -197,10 +199,13 @@ def _command_coverage_combine_powershell(args: CoverageCombineConfig) -> list[st
coverage_files = get_powershell_coverage_files() coverage_files = get_powershell_coverage_files()
def _default_stub_value(source_paths: list[str]) -> dict[str, dict[int, int]]: 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 = ['pwsh', os.path.join(ANSIBLE_TEST_TOOLS_ROOT, 'coverage_stub.ps1')]
cmd.extend(source_paths) 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) return dict((d['Path'], dict((line, 0) for line in d['Lines'])) for d in stubs)

@ -69,6 +69,7 @@ from ...util_common import (
run_command, run_command,
write_json_test_results, write_json_test_results,
check_pyyaml, check_pyyaml,
get_powershell_injector_env,
) )
from ...coverage_util import ( from ...coverage_util import (
@ -629,6 +630,7 @@ def command_integration_script(
cmd += ['-e', '@%s' % config_path] cmd += ['-e', '@%s' % config_path]
env.update(coverage_manager.get_environment(target.name, target.aliases)) 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) cover_python(args, host_state.controller_profile.python, cmd, target.name, env, cwd=cwd, capture=False)
@ -748,6 +750,7 @@ def command_integration_role(
env['ANSIBLE_ROLES_PATH'] = test_env.targets_dir env['ANSIBLE_ROLES_PATH'] = test_env.targets_dir
env.update(coverage_manager.get_environment(target.name, target.aliases)) 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) cover_python(args, host_state.controller_profile.python, cmd, target.name, env, cwd=cwd, capture=False)

@ -29,10 +29,12 @@ from ...util import (
SubprocessError, SubprocessError,
find_executable, find_executable,
ANSIBLE_TEST_DATA_ROOT, ANSIBLE_TEST_DATA_ROOT,
common_environment,
) )
from ...util_common import ( from ...util_common import (
run_command, run_command,
get_powershell_injector_env,
) )
from ...config import ( 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')] 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: 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) settings = self.load_processor(args)
paths = [target.path for target in targets.include] paths = [target.path for target in targets.include]
@ -75,7 +80,7 @@ class PslintTest(SanityVersionNeutral):
for cmd in cmds: for cmd in cmds:
try: try:
stdout, stderr = run_command(args, cmd, capture=True) stdout, stderr = run_command(args, cmd, env=env, capture=True)
status = 0 status = 0
except SubprocessError as ex: except SubprocessError as ex:
stdout = ex.stdout stdout = ex.stdout

@ -42,6 +42,7 @@ from ...util_common import (
process_scoped_temporary_directory, process_scoped_temporary_directory,
run_command, run_command,
ResultType, ResultType,
get_powershell_injector_env,
) )
from ...ansible_util import ( from ...ansible_util import (
@ -122,6 +123,7 @@ class ValidateModulesTest(SanitySingleVersion):
def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult: def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult:
env = ansible_environment(args, color=False) env = ansible_environment(args, color=False)
env.update(get_powershell_injector_env(args.controller_powershell, env))
settings = self.load_processor(args) settings = self.load_processor(args)

@ -63,7 +63,8 @@ from ...python_requirements import (
) )
from ...util_common import ( from ...util_common import (
get_injector_env, get_python_injector_env,
get_powershell_injector_env,
) )
from ...delegation import ( from ...delegation import (
@ -206,7 +207,8 @@ def get_environment_variables(
if isinstance(con, LocalConnection): # configure the controller environment if isinstance(con, LocalConnection): # configure the controller environment
env.update(ansible_environment(args)) 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)) env.update(ANSIBLE_TEST_METADATA_PATH=os.path.abspath(args.metadata_path))
if isinstance(target_profile, DebuggableProfile): if isinstance(target_profile, DebuggableProfile):

@ -11,6 +11,7 @@ import typing as t
from .constants import ( from .constants import (
CONTROLLER_PYTHON_VERSIONS, CONTROLLER_PYTHON_VERSIONS,
SUPPORTED_PYTHON_VERSIONS, SUPPORTED_PYTHON_VERSIONS,
SUPPORTED_POWERSHELL_VERSIONS,
) )
from .util import ( from .util import (
@ -71,16 +72,32 @@ class PosixCompletionConfig(CompletionConfig, metaclass=abc.ABCMeta):
def supported_pythons(self) -> list[str]: def supported_pythons(self) -> list[str]:
"""Return a list of the supported Python versions.""" """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 @abc.abstractmethod
def get_python_path(self, version: str) -> str: def get_python_path(self, version: str) -> str:
"""Return the path of the requested Python version.""" """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 it is not found.
If no version is specified, look for the default unversioned 'pwsh' interpreter.
"""
def get_default_python(self, controller: bool) -> str: def get_default_python(self, controller: bool) -> str:
"""Return the default Python version for a controller or target as specified.""" """Return the default Python version for a controller or target as specified."""
context_pythons = CONTROLLER_PYTHON_VERSIONS if controller else SUPPORTED_PYTHON_VERSIONS 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] version = [python for python in self.supported_pythons if python in context_pythons][0]
return version return version
def get_default_powershell(self) -> str | None:
"""Return the default PowerShell version, or None if there is no default."""
return None
@property @property
def controller_supported(self) -> bool: def controller_supported(self) -> bool:
"""True if at least one Python version is provided which supports the controller, otherwise False.""" """True if at least one Python version is provided which supports the controller, otherwise False."""
@ -106,6 +123,28 @@ class PythonCompletionConfig(PosixCompletionConfig, metaclass=abc.ABCMeta):
return os.path.join(self.python_dir, f'python{version}') 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/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 SUPPORTED_POWERSHELL_VERSIONS]
return versions
def get_powershell_path(self, version: str | None) -> str:
"""
Return the path of the requested PowerShell version.
If no version is specified, look for the default unversioned 'pwsh' interpreter.
"""
return os.path.join(self.powershell_dir, f'pwsh{version or ""}')
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class RemoteCompletionConfig(CompletionConfig): class RemoteCompletionConfig(CompletionConfig):
"""Base class for completion configuration of remote environments provisioned through Ansible Core CI.""" """Base class for completion configuration of remote environments provisioned through Ansible Core CI."""
@ -150,7 +189,7 @@ class InventoryCompletionConfig(CompletionConfig):
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class PosixSshCompletionConfig(PythonCompletionConfig): class PosixSshCompletionConfig(PythonCompletionConfig, PowerShellCompletionConfig):
"""Configuration for a POSIX host reachable over SSH.""" """Configuration for a POSIX host reachable over SSH."""
def __init__(self, user: str, host: str) -> None: def __init__(self, user: str, host: str) -> None:
@ -166,7 +205,7 @@ class PosixSshCompletionConfig(PythonCompletionConfig):
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class DockerCompletionConfig(PythonCompletionConfig): class DockerCompletionConfig(PythonCompletionConfig, PowerShellCompletionConfig):
"""Configuration for Docker containers.""" """Configuration for Docker containers."""
image: str = '' image: str = ''
@ -196,6 +235,10 @@ class DockerCompletionConfig(PythonCompletionConfig):
except ValueError: except ValueError:
raise ValueError(f'Docker completion entry "{self.name}" has an invalid value "{self.cgroup}" for the "cgroup" setting.') from None 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): def __post_init__(self):
if not self.image: if not self.image:
raise Exception(f'Docker completion entry "{self.name}" must provide an "image" setting.') raise Exception(f'Docker completion entry "{self.name}" must provide an "image" setting.')
@ -222,12 +265,16 @@ class NetworkRemoteCompletionConfig(RemoteCompletionConfig):
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class PosixRemoteCompletionConfig(RemoteCompletionConfig, PythonCompletionConfig): class PosixRemoteCompletionConfig(RemoteCompletionConfig, PythonCompletionConfig, PowerShellCompletionConfig):
"""Configuration for remote POSIX platforms.""" """Configuration for remote POSIX platforms."""
become: t.Optional[str] = None become: t.Optional[str] = None
placeholder: bool = False 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): def __post_init__(self):
if not self.placeholder: if not self.placeholder:
super().__post_init__() super().__post_init__()

@ -36,6 +36,7 @@ from .host_configs import (
OriginConfig, OriginConfig,
PythonConfig, PythonConfig,
VirtualPythonConfig, VirtualPythonConfig,
PowerShellConfig,
) )
@ -91,6 +92,14 @@ class EnvironmentConfig(CommonConfig):
Only available after delegation has been performed or skipped (if delegation is not required). 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: if self.host_path:
self.delegate = False self.delegate = False
else: else:

@ -19,6 +19,13 @@ TIMEOUT_PATH = '.ansible-test-timeout.json'
CONTROLLER_MIN_PYTHON_VERSION = CONTROLLER_PYTHON_VERSIONS[0] CONTROLLER_MIN_PYTHON_VERSION = CONTROLLER_PYTHON_VERSIONS[0]
SUPPORTED_PYTHON_VERSIONS = REMOTE_ONLY_PYTHON_VERSIONS + CONTROLLER_PYTHON_VERSIONS SUPPORTED_PYTHON_VERSIONS = REMOTE_ONLY_PYTHON_VERSIONS + CONTROLLER_PYTHON_VERSIONS
SUPPORTED_POWERSHELL_VERSIONS = [
'7.6',
'7.5',
'7.4',
]
"""PowerShell versions supported by ansible-test, listed in order of preference."""
REMOTE_PROVIDERS = [ REMOTE_PROVIDERS = [
'default', 'default',
'aws', 'aws',

@ -12,6 +12,7 @@ import typing as t
from .constants import ( from .constants import (
SUPPORTED_PYTHON_VERSIONS, SUPPORTED_PYTHON_VERSIONS,
SUPPORTED_POWERSHELL_VERSIONS,
) )
from .io import ( from .io import (
@ -43,6 +44,7 @@ from .util import (
str_to_version, str_to_version,
version_to_str, version_to_str,
Architecture, Architecture,
find_executable,
) )
@ -66,6 +68,21 @@ class OriginCompletionConfig(PosixCompletionConfig):
version = find_python(version) version = find_python(version)
return version return version
@property
def supported_powershells(self) -> list[str]:
"""Return a list of the supported PowerShell versions."""
return SUPPORTED_POWERSHELL_VERSIONS
def get_powershell_path(self, version: str | None) -> str | None:
"""
Return the path of the requested PowerShell version, or None if it is not found.
If no version is specified, look for the default unversioned 'pwsh' interpreter.
"""
if not version:
pass # FIXME: when does the origin have no version, and what to do about it?
return find_executable(f'pwsh{version or ""}', required=bool(version)) # FIXME: is this the behavior we want?
@property @property
def is_default(self) -> bool: def is_default(self) -> bool:
"""True if the completion entry is only used for defaults, otherwise False.""" """True if the completion entry is only used for defaults, otherwise False."""
@ -179,11 +196,43 @@ class VirtualPythonConfig(PythonConfig):
return True return True
@dataclasses.dataclass
class PowerShellConfig:
"""Configuration for PowerShell."""
version: str | None = None
path: str | None = None
@property
def tuple(self) -> tuple[int, ...]:
"""Return the PowerShell version as a tuple."""
return str_to_version(self.version)
@property
def major_version(self) -> int:
"""Return the PowerShell major version."""
return self.tuple[0]
def apply_defaults(self, context: HostContext, 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 ""}') # FIXME: is the version adding and/or fallback behavior what we want?
# FUTURE: If the host is origin, the pwsh path could be validated here.
else:
self.path = defaults.get_powershell_path(self.version)
@dataclasses.dataclass @dataclasses.dataclass
class PosixConfig(HostConfig, metaclass=abc.ABCMeta): class PosixConfig(HostConfig, metaclass=abc.ABCMeta):
"""Base class for POSIX host configuration.""" """Base class for POSIX host configuration."""
python: t.Optional[PythonConfig] = None python: t.Optional[PythonConfig] = None
powershell: PowerShellConfig | None = None
@property @property
@abc.abstractmethod @abc.abstractmethod
@ -203,6 +252,9 @@ class PosixConfig(HostConfig, metaclass=abc.ABCMeta):
self.python = self.python or NativePythonConfig() self.python = self.python or NativePythonConfig()
self.python.apply_defaults(context, defaults) self.python.apply_defaults(context, defaults)
self.powershell = self.powershell or PowerShellConfig()
self.powershell.apply_defaults(context, defaults)
@dataclasses.dataclass @dataclasses.dataclass
class ControllerHostConfig(PosixConfig, metaclass=abc.ABCMeta): class ControllerHostConfig(PosixConfig, metaclass=abc.ABCMeta):
@ -492,6 +544,11 @@ 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. # 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 self.python = context.controller_config.python
if not self.powershell and not defaults.supported_powershells:
# FIXME: is this logic correct?
# 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) super().apply_defaults(context, defaults)
@property @property

@ -40,6 +40,7 @@ from .host_configs import (
VirtualPythonConfig, VirtualPythonConfig,
WindowsInventoryConfig, WindowsInventoryConfig,
WindowsRemoteConfig, WindowsRemoteConfig,
PowerShellConfig,
) )
from .core_ci import ( from .core_ci import (
@ -474,6 +475,20 @@ class PosixProfile[TPosixConfig: PosixConfig](HostProfile[TPosixConfig], metacla
return python 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
class ControllerHostProfile[T: ControllerHostConfig](PosixProfile[T], DebuggableProfile[T], metaclass=abc.ABCMeta): class ControllerHostProfile[T: ControllerHostConfig](PosixProfile[T], DebuggableProfile[T], metaclass=abc.ABCMeta):
"""Base class for profiles usable as a controller.""" """Base class for profiles usable as a controller."""

@ -147,6 +147,7 @@ def prepare_profiles[TEnvironmentConfig: EnvironmentConfig](
if not args.delegate: if not args.delegate:
check_controller_python(args, host_state) check_controller_python(args, host_state)
check_controller_powershell(args, host_state)
if requirements: if requirements:
requirements(host_state.controller_profile) requirements(host_state.controller_profile)
@ -184,6 +185,13 @@ def check_controller_python(args: EnvironmentConfig, host_state: HostState) -> N
args.controller_python = controller_python 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: def cleanup_profiles(host_state: HostState) -> None:
"""Cleanup provisioned hosts when exiting.""" """Cleanup provisioned hosts when exiting."""
for profile in host_state.profiles: for profile in host_state.profiles:

@ -58,6 +58,7 @@ from .constants import (
) )
PYTHON_PATHS: dict[str, str] = {} PYTHON_PATHS: dict[str, str] = {}
POWERSHELL_PATHS: dict[str, str] = {}
COVERAGE_CONFIG_NAME = 'coveragerc' COVERAGE_CONFIG_NAME = 'coveragerc'

@ -4,6 +4,7 @@ from __future__ import annotations
import collections.abc as c import collections.abc as c
import contextlib import contextlib
import functools
import json import json
import os import os
import re import re
@ -31,6 +32,7 @@ from .util import (
MODE_FILE, MODE_FILE,
OutputStream, OutputStream,
PYTHON_PATHS, PYTHON_PATHS,
POWERSHELL_PATHS,
raw_command, raw_command,
ANSIBLE_TEST_DATA_ROOT, ANSIBLE_TEST_DATA_ROOT,
ANSIBLE_TEST_TARGET_ROOT, ANSIBLE_TEST_TARGET_ROOT,
@ -58,7 +60,7 @@ from .provider.layout import (
from .host_configs import ( from .host_configs import (
PythonConfig, PythonConfig,
VirtualPythonConfig, VirtualPythonConfig, PowerShellConfig,
) )
CHECK_YAML_VERSIONS: dict[str, t.Any] = {} CHECK_YAML_VERSIONS: dict[str, t.Any] = {}
@ -297,7 +299,7 @@ def write_text_test_results(category: ResultType, name: str, content: str) -> No
@cache @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.""" """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') injector_path = tempfile.mkdtemp(prefix='ansible-test-', suffix='-injector', dir='/tmp')
@ -365,18 +367,28 @@ def set_shebang(script: str, executable: str) -> str:
def get_python_path(interpreter: 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.""" """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' suffix = '-ansible'
root_temp_dir = '/tmp' root_temp_dir = '/tmp'
python_path = tempfile.mkdtemp(prefix=prefix, suffix=suffix, dir=root_temp_dir) injected_path = tempfile.mkdtemp(prefix=prefix, suffix=suffix, dir=root_temp_dir)
injected_interpreter = os.path.join(python_path, 'python') 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. # 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: # There are several scenarios known not to work with symlinks:
@ -390,14 +402,14 @@ def get_python_path(interpreter: str) -> str:
create_interpreter_wrapper(interpreter, injected_interpreter) create_interpreter_wrapper(interpreter, injected_interpreter)
verified_chmod(python_path, MODE_DIRECTORY) verified_chmod(injected_path, MODE_DIRECTORY)
if not PYTHON_PATHS: if not cached_paths:
ExitHandler.register(cleanup_python_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: def create_temp_dir(prefix: t.Optional[str] = None, suffix: t.Optional[str] = None, base_dir: t.Optional[str] = None) -> str:
@ -408,33 +420,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: 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 # 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 # injected_interpreter could be a script from the system or our own wrapper created for the --venv option
shebang_interpreter = sys.executable shebang_interpreter = sys.executable
code = textwrap.dedent(""" code = textwrap.dedent(f"""
#!%s #!{shebang_interpreter}
from __future__ import annotations from __future__ import annotations
from os import execv from os import execv
from sys import argv from sys import argv
python = '%s' interpreter = {interpreter!r}
execv(python, [python] + argv[1:]) execv(interpreter, [interpreter] + argv[1:])
""" % (shebang_interpreter, interpreter)).lstrip() """).lstrip()
write_text_file(injected_interpreter, code) write_text_file(injected_interpreter, code)
verified_chmod(injected_interpreter, MODE_FILE_EXECUTE) verified_chmod(injected_interpreter, MODE_FILE_EXECUTE)
def cleanup_python_paths() -> None: def cleanup_injector_paths(cached_paths: dict[str, str]) -> None:
"""Clean up all temporary python directories.""" """Clean up all temporary injector directories."""
for path in sorted(PYTHON_PATHS.values()): for path in sorted(cached_paths.values()):
display.info('Cleaning up temporary python directory: %s' % path, verbosity=2) display.info(f'Cleaning up temporary injector directory: {path}', verbosity=2)
remove_tree(path) remove_tree(path)
@ -454,18 +466,18 @@ def intercept_python(
Otherwise, a temporary directory will be created to ensure the correct Python can be found in PATH. Otherwise, a temporary directory will be created to ensure the correct Python can be found in PATH.
""" """
cmd = list(cmd) 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) 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, python: PythonConfig,
env: dict[str, str], env: dict[str, str],
) -> dict[str, str]: ) -> dict[str, str]:
"""Get the environment variables needed to inject the given Python interpreter into the environment.""" """Get the environment variables needed to inject the given Python interpreter into the environment."""
env = env.copy() 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 # make sure scripts (including injector.py) find the correct Python interpreter
if isinstance(python, VirtualPythonConfig): if isinstance(python, VirtualPythonConfig):
@ -480,6 +492,25 @@ def get_injector_env(
return 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 or not powershell.version:
return env # FIXME: how should the absence of pwsh and/or no powershell version specified be handled?
powershell_path = get_powershell_path(powershell.path)
env['PATH'] = os.path.pathsep.join([powershell_path, env['PATH']])
env['ANSIBLE_TEST_POWERSHELL_VERSION'] = powershell.version
env['ANSIBLE_TEST_POWERSHELL_INTERPRETER'] = powershell.path
return env
def run_command( def run_command(
args: CommonConfig, args: CommonConfig,
cmd: c.Iterable[str], cmd: c.Iterable[str],

Loading…
Cancel
Save