diff --git a/changelogs/fragments/ansible-test-powershell.yml b/changelogs/fragments/ansible-test-powershell.yml new file mode 100644 index 00000000000..fcdba2bab7c --- /dev/null +++ b/changelogs/fragments/ansible-test-powershell.yml @@ -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. diff --git a/test/integration/targets/powershell-controller/aliases b/test/integration/targets/powershell-controller/aliases new file mode 100644 index 00000000000..941a08e2eb7 --- /dev/null +++ b/test/integration/targets/powershell-controller/aliases @@ -0,0 +1,4 @@ +context/controller +gather_facts/no +shippable/posix/group5 +shippable/generic/group1 # runs in the default test container diff --git a/test/integration/targets/powershell-controller/tasks/main.yml b/test/integration/targets/powershell-controller/tasks/main.yml new file mode 100644 index 00000000000..d600fb7d809 --- /dev/null +++ b/test/integration/targets/powershell-controller/tasks/main.yml @@ -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 }}" diff --git a/test/integration/targets/powershell-posix-target/aliases b/test/integration/targets/powershell-posix-target/aliases new file mode 100644 index 00000000000..e4a24bcd247 --- /dev/null +++ b/test/integration/targets/powershell-posix-target/aliases @@ -0,0 +1,4 @@ +context/target +gather_facts/no +shippable/posix/group2 +shippable/generic/group1 # runs in the default test container diff --git a/test/integration/targets/powershell-posix-target/tasks/main.yml b/test/integration/targets/powershell-posix-target/tasks/main.yml new file mode 100644 index 00000000000..d600fb7d809 --- /dev/null +++ b/test/integration/targets/powershell-posix-target/tasks/main.yml @@ -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 }}" diff --git a/test/integration/targets/powershell-windows-target/aliases b/test/integration/targets/powershell-windows-target/aliases new file mode 100644 index 00000000000..b8e06dba3c3 --- /dev/null +++ b/test/integration/targets/powershell-windows-target/aliases @@ -0,0 +1,5 @@ +context/target +gather_facts/no +shippable/windows/group1 +shippable/windows/smoketest +windows diff --git a/test/integration/targets/powershell-windows-target/tasks/main.yml b/test/integration/targets/powershell-windows-target/tasks/main.yml new file mode 100644 index 00000000000..9283078d5b8 --- /dev/null +++ b/test/integration/targets/powershell-windows-target/tasks/main.yml @@ -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 }}" diff --git a/test/integration/targets/powershell/aliases b/test/integration/targets/powershell/aliases deleted file mode 100644 index d79956515e8..00000000000 --- a/test/integration/targets/powershell/aliases +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 3671f098dbb..00000000000 --- a/test/integration/targets/powershell/tasks/main.yml +++ /dev/null @@ -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 }}" diff --git a/test/lib/ansible_test/_data/completion/docker.txt b/test/lib/ansible_test/_data/completion/docker.txt index fb373a68355..e0fec63a8e0 100644 --- a/test/lib/ansible_test/_data/completion/docker.txt +++ b/test/lib/ansible_test/_data/completion/docker.txt @@ -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 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/bootstrap.py b/test/lib/ansible_test/_internal/bootstrap.py index a755d37991f..946c81332ec 100644 --- a/test/lib/ansible_test/_internal/bootstrap.py +++ b/test/lib/ansible_test/_internal/bootstrap.py @@ -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.""" 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..95d94c4db60 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,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}}}' 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..7297a952e55 100644 --- a/test/lib/ansible_test/_internal/cli/parsers/value_parsers.py +++ b/test/lib/ansible_test/_internal/cli/parsers/value_parsers.py @@ -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.""" 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 8881877ea91..676bb43b0ad 100644 --- a/test/lib/ansible_test/_internal/commands/integration/__init__.py +++ b/test/lib/ansible_test/_internal/commands/integration/__init__.py @@ -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) 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..49b37bbf537 100644 --- a/test/lib/ansible_test/_internal/completion.py +++ b/test/lib/ansible_test/_internal/completion.py @@ -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__() 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..03fbc857d92 100644 --- a/test/lib/ansible_test/_internal/constants.py +++ b/test/lib/ansible_test/_internal/constants.py @@ -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', diff --git a/test/lib/ansible_test/_internal/host_configs.py b/test/lib/ansible_test/_internal/host_configs.py index 8753ba9160d..82b3ec0106d 100644 --- a/test/lib/ansible_test/_internal/host_configs.py +++ b/test/lib/ansible_test/_internal/host_configs.py @@ -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 diff --git a/test/lib/ansible_test/_internal/host_profiles.py b/test/lib/ansible_test/_internal/host_profiles.py index fcfb88f5eb9..09f0c761851 100644 --- a/test/lib/ansible_test/_internal/host_profiles.py +++ b/test/lib/ansible_test/_internal/host_profiles.py @@ -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) diff --git a/test/lib/ansible_test/_internal/inventory.py b/test/lib/ansible_test/_internal/inventory.py index 4aa2fd008f1..0ac630a1564 100644 --- a/test/lib/ansible_test/_internal/inventory.py +++ b/test/lib/ansible_test/_internal/inventory.py @@ -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) 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/ssh.py b/test/lib/ansible_test/_internal/ssh.py index 9660b2f676d..752145a19a7 100644 --- a/test/lib/ansible_test/_internal/ssh.py +++ b/test/lib/ansible_test/_internal/ssh.py @@ -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', diff --git a/test/lib/ansible_test/_internal/util.py b/test/lib/ansible_test/_internal/util.py index 5ed60ea3b01..a8fce834e48 100644 --- a/test/lib/ansible_test/_internal/util.py +++ b/test/lib/ansible_test/_internal/util.py @@ -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() diff --git a/test/lib/ansible_test/_internal/util_common.py b/test/lib/ansible_test/_internal/util_common.py index d2464b56aa3..188504d18f6 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, @@ -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], diff --git a/test/lib/ansible_test/_util/target/setup/bootstrap.ps1 b/test/lib/ansible_test/_util/target/setup/bootstrap.ps1 new file mode 100644 index 00000000000..96050d72b5f --- /dev/null +++ b/test/lib/ansible_test/_util/target/setup/bootstrap.ps1 @@ -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 diff --git a/test/lib/ansible_test/_util/target/setup/bootstrap.sh b/test/lib/ansible_test/_util/target/setup/bootstrap.sh index 74aec8b237c..5aeed7bc7b3 100644 --- a/test/lib/ansible_test/_util/target/setup/bootstrap.sh +++ b/test/lib/ansible_test/_util/target/setup/bootstrap.sh @@ -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 diff --git a/test/lib/ansible_test/_util/target/setup/entrypoint.ps1 b/test/lib/ansible_test/_util/target/setup/entrypoint.ps1 new file mode 100644 index 00000000000..484dce7a472 --- /dev/null +++ b/test/lib/ansible_test/_util/target/setup/entrypoint.ps1 @@ -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 +}