diff --git a/test/integration/targets/ansible-test-installed/ansible_collections/ns/col/tests/integration/targets/installed/runme.sh b/test/integration/targets/ansible-test-installed/ansible_collections/ns/col/tests/integration/targets/installed/runme.sh index 9de3820a31c..e5b425af1fe 100755 --- a/test/integration/targets/ansible-test-installed/ansible_collections/ns/col/tests/integration/targets/installed/runme.sh +++ b/test/integration/targets/ansible-test-installed/ansible_collections/ns/col/tests/integration/targets/installed/runme.sh @@ -4,8 +4,8 @@ set -eux -# The third PATH entry is the injected bin directory created by ansible-test. -bin_dir="$(python -c 'import os; print(os.environ["PATH"].split(":")[2])')" +# The PATH entry ending in "-bin" is the injected bin directory created by ansible-test. +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 do diff --git a/test/integration/targets/ansible-test-sanity-validate-modules/runme.sh b/test/integration/targets/ansible-test-sanity-validate-modules/runme.sh index 5e2365ab259..132f2f9d8c6 100755 --- a/test/integration/targets/ansible-test-sanity-validate-modules/runme.sh +++ b/test/integration/targets/ansible-test-sanity-validate-modules/runme.sh @@ -25,6 +25,8 @@ if ! command -V pwsh; then exit 0 fi +pwsh --version + # 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 "${@}" diff --git a/test/integration/targets/powershell/aliases b/test/integration/targets/powershell/aliases new file mode 100644 index 00000000000..d79956515e8 --- /dev/null +++ b/test/integration/targets/powershell/aliases @@ -0,0 +1,3 @@ +context/target +gather_facts/no +shippable/posix/group2 diff --git a/test/integration/targets/powershell/tasks/main.yml b/test/integration/targets/powershell/tasks/main.yml new file mode 100644 index 00000000000..3671f098dbb --- /dev/null +++ b/test/integration/targets/powershell/tasks/main.yml @@ -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 }}" diff --git a/test/lib/ansible_test/_data/completion/docker.txt b/test/lib/ansible_test/_data/completion/docker.txt index e53d05e8084..0efcb43e212 100644 --- a/test/lib/ansible_test/_data/completion/docker.txt +++ b/test/lib/ansible_test/_data/completion/docker.txt @@ -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 -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-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 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 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 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 diff --git a/test/lib/ansible_test/_internal/ansible_util.py b/test/lib/ansible_test/_internal/ansible_util.py index 16eb8bdf8de..7a7b5146110 100644 --- a/test/lib/ansible_test/_internal/ansible_util.py +++ b/test/lib/ansible_test/_internal/ansible_util.py @@ -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): diff --git a/test/lib/ansible_test/_internal/cli/parsers/key_value_parsers.py b/test/lib/ansible_test/_internal/cli/parsers/key_value_parsers.py index 3da6313ed67..6e35c1d1d31 100644 --- a/test/lib/ansible_test/_internal/cli/parsers/key_value_parsers.py +++ b/test/lib/ansible_test/_internal/cli/parsers/key_value_parsers.py @@ -43,6 +43,7 @@ from ..argparsing.parsers import ( from .value_parsers import ( PythonParser, + PowerShellParser, ) from .helpers import ( @@ -61,6 +62,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]: @@ -87,6 +89,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]: @@ -113,6 +116,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), @@ -153,6 +157,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]: @@ -228,6 +233,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=PowerShellParser(), ) def document(self, state: DocumentationState) -> t.Optional[str]: diff --git a/test/lib/ansible_test/_internal/cli/parsers/value_parsers.py b/test/lib/ansible_test/_internal/cli/parsers/value_parsers.py index e97893c3640..11d9a283738 100644 --- a/test/lib/ansible_test/_internal/cli/parsers/value_parsers.py +++ b/test/lib/ansible_test/_internal/cli/parsers/value_parsers.py @@ -5,10 +5,15 @@ from __future__ import annotations import collections.abc as c import typing as t +from ...constants import ( + SUPPORTED_POWERSHELL_VERSIONS, +) + from ...host_configs import ( NativePythonConfig, PythonConfig, VirtualPythonConfig, + PowerShellConfig, ) from ..argparsing.parsers import ( @@ -135,6 +140,20 @@ class PythonParser(Parser): 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): """Composite argument parser for "{platform}/{version}" formatted choices.""" diff --git a/test/lib/ansible_test/_internal/commands/coverage/combine.py b/test/lib/ansible_test/_internal/commands/coverage/combine.py index 2cd402c2c4d..16a5e546a38 100644 --- a/test/lib/ansible_test/_internal/commands/coverage/combine.py +++ b/test/lib/ansible_test/_internal/commands/coverage/combine.py @@ -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) diff --git a/test/lib/ansible_test/_internal/commands/integration/__init__.py b/test/lib/ansible_test/_internal/commands/integration/__init__.py index 421c3d4a3d4..8c575071d21 100644 --- a/test/lib/ansible_test/_internal/commands/integration/__init__.py +++ b/test/lib/ansible_test/_internal/commands/integration/__init__.py @@ -69,6 +69,7 @@ from ...util_common import ( run_command, write_json_test_results, check_pyyaml, + get_powershell_injector_env, ) from ...coverage_util import ( @@ -629,6 +630,7 @@ def command_integration_script( cmd += ['-e', '@%s' % config_path] 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) @@ -748,6 +750,7 @@ def command_integration_role( env['ANSIBLE_ROLES_PATH'] = test_env.targets_dir 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) diff --git a/test/lib/ansible_test/_internal/commands/sanity/pslint.py b/test/lib/ansible_test/_internal/commands/sanity/pslint.py index 6c923878425..d7860eb5a4f 100644 --- a/test/lib/ansible_test/_internal/commands/sanity/pslint.py +++ b/test/lib/ansible_test/_internal/commands/sanity/pslint.py @@ -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 diff --git a/test/lib/ansible_test/_internal/commands/sanity/validate_modules.py b/test/lib/ansible_test/_internal/commands/sanity/validate_modules.py index b51582e4e90..ba1e8beba24 100644 --- a/test/lib/ansible_test/_internal/commands/sanity/validate_modules.py +++ b/test/lib/ansible_test/_internal/commands/sanity/validate_modules.py @@ -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) diff --git a/test/lib/ansible_test/_internal/commands/shell/__init__.py b/test/lib/ansible_test/_internal/commands/shell/__init__.py index 89fbd8d72c8..c6733849d87 100644 --- a/test/lib/ansible_test/_internal/commands/shell/__init__.py +++ b/test/lib/ansible_test/_internal/commands/shell/__init__.py @@ -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): diff --git a/test/lib/ansible_test/_internal/completion.py b/test/lib/ansible_test/_internal/completion.py index 874ee2bd20b..7e7241c7770 100644 --- a/test/lib/ansible_test/_internal/completion.py +++ b/test/lib/ansible_test/_internal/completion.py @@ -11,6 +11,7 @@ import typing as t from .constants import ( CONTROLLER_PYTHON_VERSIONS, SUPPORTED_PYTHON_VERSIONS, + SUPPORTED_POWERSHELL_VERSIONS, ) from .util import ( @@ -71,16 +72,32 @@ 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 it is not found. + If no version is specified, look for the default unversioned 'pwsh' interpreter. + """ + 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 +123,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/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) class RemoteCompletionConfig(CompletionConfig): """Base class for completion configuration of remote environments provisioned through Ansible Core CI.""" @@ -150,7 +189,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 +205,7 @@ class PosixSshCompletionConfig(PythonCompletionConfig): @dataclasses.dataclass(frozen=True) -class DockerCompletionConfig(PythonCompletionConfig): +class DockerCompletionConfig(PythonCompletionConfig, PowerShellCompletionConfig): """Configuration for Docker containers.""" image: str = '' @@ -196,6 +235,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 +265,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__() diff --git a/test/lib/ansible_test/_internal/config.py b/test/lib/ansible_test/_internal/config.py index a9abc3c875f..10adaf9fbf5 100644 --- a/test/lib/ansible_test/_internal/config.py +++ b/test/lib/ansible_test/_internal/config.py @@ -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: diff --git a/test/lib/ansible_test/_internal/constants.py b/test/lib/ansible_test/_internal/constants.py index 2b6d72b052f..cfddf46d0ce 100644 --- a/test/lib/ansible_test/_internal/constants.py +++ b/test/lib/ansible_test/_internal/constants.py @@ -19,6 +19,13 @@ 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', + '7.5', + '7.4', +] +"""PowerShell versions supported by ansible-test, listed in order of preference.""" + REMOTE_PROVIDERS = [ 'default', 'aws', diff --git a/test/lib/ansible_test/_internal/host_configs.py b/test/lib/ansible_test/_internal/host_configs.py index 8753ba9160d..4dd6ca38145 100644 --- a/test/lib/ansible_test/_internal/host_configs.py +++ b/test/lib/ansible_test/_internal/host_configs.py @@ -12,6 +12,7 @@ import typing as t from .constants import ( SUPPORTED_PYTHON_VERSIONS, + SUPPORTED_POWERSHELL_VERSIONS, ) from .io import ( @@ -43,6 +44,7 @@ from .util import ( str_to_version, version_to_str, Architecture, + find_executable, ) @@ -66,6 +68,21 @@ 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 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 def is_default(self) -> bool: """True if the completion entry is only used for defaults, otherwise False.""" @@ -179,11 +196,43 @@ class VirtualPythonConfig(PythonConfig): 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 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 +252,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(context, defaults) + @dataclasses.dataclass 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. 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) @property diff --git a/test/lib/ansible_test/_internal/host_profiles.py b/test/lib/ansible_test/_internal/host_profiles.py index fcfb88f5eb9..35d508fa113 100644 --- a/test/lib/ansible_test/_internal/host_profiles.py +++ b/test/lib/ansible_test/_internal/host_profiles.py @@ -40,6 +40,7 @@ from .host_configs import ( VirtualPythonConfig, WindowsInventoryConfig, WindowsRemoteConfig, + PowerShellConfig, ) from .core_ci import ( @@ -474,6 +475,20 @@ 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 + class ControllerHostProfile[T: ControllerHostConfig](PosixProfile[T], DebuggableProfile[T], metaclass=abc.ABCMeta): """Base class for profiles usable as a controller.""" diff --git a/test/lib/ansible_test/_internal/provisioning.py b/test/lib/ansible_test/_internal/provisioning.py index cc3235d037e..b8b7cef6220 100644 --- a/test/lib/ansible_test/_internal/provisioning.py +++ b/test/lib/ansible_test/_internal/provisioning.py @@ -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: diff --git a/test/lib/ansible_test/_internal/util.py b/test/lib/ansible_test/_internal/util.py index 5ed60ea3b01..53afc5d61ba 100644 --- a/test/lib/ansible_test/_internal/util.py +++ b/test/lib/ansible_test/_internal/util.py @@ -58,6 +58,7 @@ from .constants import ( ) PYTHON_PATHS: dict[str, str] = {} +POWERSHELL_PATHS: dict[str, str] = {} COVERAGE_CONFIG_NAME = 'coveragerc' diff --git a/test/lib/ansible_test/_internal/util_common.py b/test/lib/ansible_test/_internal/util_common.py index d2464b56aa3..0d24054798d 100644 --- a/test/lib/ansible_test/_internal/util_common.py +++ b/test/lib/ansible_test/_internal/util_common.py @@ -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, @@ -58,7 +60,7 @@ from .provider.layout import ( from .host_configs import ( PythonConfig, - VirtualPythonConfig, + VirtualPythonConfig, PowerShellConfig, ) CHECK_YAML_VERSIONS: dict[str, t.Any] = {} @@ -297,7 +299,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 +367,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 +402,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 +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: - """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 +466,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 +492,25 @@ 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 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( args: CommonConfig, cmd: c.Iterable[str],