ansible-test - Support pwsh version selection (#86238)

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