ansible-test - Add multi-arch remote support.

pull/77715/head
Matt Clay 4 years ago
parent de5d6820f8
commit 2cc74b04c4

@ -0,0 +1,2 @@
minor_changes:
- ansible-test - Add support for multi-arch remotes.

@ -1,2 +1,2 @@
ios/csr1000v collection=cisco.ios connection=ansible.netcommon.network_cli provider=aws ios/csr1000v collection=cisco.ios connection=ansible.netcommon.network_cli provider=aws arch=x86_64
vyos/1.1.8 collection=vyos.vyos connection=ansible.netcommon.network_cli provider=aws vyos/1.1.8 collection=vyos.vyos connection=ansible.netcommon.network_cli provider=aws arch=x86_64

@ -1,10 +1,10 @@
freebsd/12.3 python=3.8 python_dir=/usr/local/bin provider=aws freebsd/12.3 python=3.8 python_dir=/usr/local/bin provider=aws arch=x86_64
freebsd/13.0 python=3.7,3.8,3.9 python_dir=/usr/local/bin provider=aws freebsd/13.0 python=3.7,3.8,3.9 python_dir=/usr/local/bin provider=aws arch=x86_64
freebsd python_dir=/usr/local/bin provider=aws freebsd python_dir=/usr/local/bin provider=aws arch=x86_64
macos/12.0 python=3.10 python_dir=/usr/local/bin provider=parallels macos/12.0 python=3.10 python_dir=/usr/local/bin provider=parallels arch=x86_64
macos python_dir=/usr/local/bin provider=parallels macos python_dir=/usr/local/bin provider=parallels arch=x86_64
rhel/7.9 python=2.7 provider=aws rhel/7.9 python=2.7 provider=aws arch=x86_64
rhel/8.5 python=3.6,3.8,3.9 provider=aws rhel/8.5 python=3.6,3.8,3.9 provider=aws arch=x86_64
rhel provider=aws rhel provider=aws arch=x86_64
ubuntu/22.04 python=3.10 provider=aws ubuntu/22.04 python=3.10 provider=aws arch=x86_64
ubuntu provider=aws ubuntu provider=aws arch=x86_64

@ -1,6 +1,6 @@
windows/2012 provider=aws windows/2012 provider=aws arch=x86_64
windows/2012-R2 provider=aws windows/2012-R2 provider=aws arch=x86_64
windows/2016 provider=aws windows/2016 provider=aws arch=x86_64
windows/2019 provider=aws windows/2019 provider=aws arch=x86_64
windows/2022 provider=aws windows/2022 provider=aws arch=x86_64
windows provider=aws windows provider=aws arch=x86_64

@ -93,6 +93,18 @@ class PythonVersionUnspecifiedError(ApplicationError):
super().__init__(f'A Python version was not specified for environment `{context}`. Use the `--python` option to specify a Python version.') super().__init__(f'A Python version was not specified for environment `{context}`. Use the `--python` option to specify a Python version.')
class RemoteProviderUnspecifiedError(ApplicationError):
"""A remote provider was not specified for a context which is unknown, thus the remote provider version is unknown."""
def __init__(self, context):
super().__init__(f'A remote provider was not specified for environment `{context}`. Use the `--remote-provider` option to specify a provider.')
class RemoteArchitectureUnspecifiedError(ApplicationError):
"""A remote architecture was not specified for a context which is unknown, thus the remote architecture version is unknown."""
def __init__(self, context):
super().__init__(f'A remote architecture was not specified for environment `{context}`. Use the `--remote-arch` option to specify an architecture.')
class ControllerNotSupportedError(ApplicationError): class ControllerNotSupportedError(ApplicationError):
"""Option(s) were specified which do not provide support for the controller and would be ignored because they are irrelevant for the target.""" """Option(s) were specified which do not provide support for the controller and would be ignored because they are irrelevant for the target."""
def __init__(self, context): def __init__(self, context):
@ -115,6 +127,7 @@ class LegacyHostOptions:
venv_system_site_packages: t.Optional[bool] = None venv_system_site_packages: t.Optional[bool] = None
remote: t.Optional[str] = None remote: t.Optional[str] = None
remote_provider: t.Optional[str] = None remote_provider: t.Optional[str] = None
remote_arch: t.Optional[str] = None
docker: t.Optional[str] = None docker: t.Optional[str] = None
docker_privileged: t.Optional[bool] = None docker_privileged: t.Optional[bool] = None
docker_seccomp: t.Optional[str] = None docker_seccomp: t.Optional[str] = None
@ -371,33 +384,40 @@ def get_legacy_host_config(
if remote_config.controller_supported: if remote_config.controller_supported:
if controller_python(options.python) or not options.python: if controller_python(options.python) or not options.python:
controller = PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider) controller = PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider,
arch=options.remote_arch)
targets = controller_targets(mode, options, controller) targets = controller_targets(mode, options, controller)
else: else:
controller_fallback = f'remote:{options.remote}', f'--remote {options.remote} --python {options.python}', FallbackReason.PYTHON controller_fallback = f'remote:{options.remote}', f'--remote {options.remote} --python {options.python}', FallbackReason.PYTHON
controller = PosixRemoteConfig(name=options.remote, provider=options.remote_provider) controller = PosixRemoteConfig(name=options.remote, provider=options.remote_provider, arch=options.remote_arch)
targets = controller_targets(mode, options, controller) targets = controller_targets(mode, options, controller)
else: else:
context, reason = f'--remote {options.remote}', FallbackReason.ENVIRONMENT context, reason = f'--remote {options.remote}', FallbackReason.ENVIRONMENT
controller = None controller = None
targets = [PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider)] targets = [PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider, arch=options.remote_arch)]
elif mode == TargetMode.SHELL and options.remote.startswith('windows/'): elif mode == TargetMode.SHELL and options.remote.startswith('windows/'):
if options.python and options.python not in CONTROLLER_PYTHON_VERSIONS: if options.python and options.python not in CONTROLLER_PYTHON_VERSIONS:
raise ControllerNotSupportedError(f'--python {options.python}') raise ControllerNotSupportedError(f'--python {options.python}')
controller = OriginConfig(python=native_python(options)) controller = OriginConfig(python=native_python(options))
targets = [WindowsRemoteConfig(name=options.remote, provider=options.remote_provider)] targets = [WindowsRemoteConfig(name=options.remote, provider=options.remote_provider, arch=options.remote_arch)]
else: else:
if not options.python: if not options.python:
raise PythonVersionUnspecifiedError(f'--remote {options.remote}') raise PythonVersionUnspecifiedError(f'--remote {options.remote}')
if not options.remote_provider:
raise RemoteProviderUnspecifiedError(f'--remote {options.remote}')
if not options.remote_arch:
raise RemoteArchitectureUnspecifiedError(f'--remote {options.remote}')
if controller_python(options.python): if controller_python(options.python):
controller = PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider) controller = PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider, arch=options.remote_arch)
targets = controller_targets(mode, options, controller) targets = controller_targets(mode, options, controller)
else: else:
context, reason = f'--remote {options.remote} --python {options.python}', FallbackReason.PYTHON context, reason = f'--remote {options.remote} --python {options.python}', FallbackReason.PYTHON
controller = None controller = None
targets = [PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider)] targets = [PosixRemoteConfig(name=options.remote, python=native_python(options), provider=options.remote_provider, arch=options.remote_arch)]
if not controller: if not controller:
if docker_available(): if docker_available():
@ -455,12 +475,13 @@ def handle_non_posix_targets(
"""Return a list of non-POSIX targets if the target mode is non-POSIX.""" """Return a list of non-POSIX targets if the target mode is non-POSIX."""
if mode == TargetMode.WINDOWS_INTEGRATION: if mode == TargetMode.WINDOWS_INTEGRATION:
if options.windows: if options.windows:
targets = [WindowsRemoteConfig(name=f'windows/{version}', provider=options.remote_provider) for version in options.windows] targets = [WindowsRemoteConfig(name=f'windows/{version}', provider=options.remote_provider, arch=options.remote_arch)
for version in options.windows]
else: else:
targets = [WindowsInventoryConfig(path=options.inventory)] targets = [WindowsInventoryConfig(path=options.inventory)]
elif mode == TargetMode.NETWORK_INTEGRATION: elif mode == TargetMode.NETWORK_INTEGRATION:
if options.platform: if options.platform:
network_targets = [NetworkRemoteConfig(name=platform, provider=options.remote_provider) for platform in options.platform] network_targets = [NetworkRemoteConfig(name=platform, provider=options.remote_provider, arch=options.remote_arch) for platform in options.platform]
for platform, collection in options.platform_collection or []: for platform, collection in options.platform_collection or []:
for entry in network_targets: for entry in network_targets:

@ -13,6 +13,10 @@ from ..constants import (
SUPPORTED_PYTHON_VERSIONS, SUPPORTED_PYTHON_VERSIONS,
) )
from ..util import (
REMOTE_ARCHITECTURES,
)
from ..completion import ( from ..completion import (
docker_completion, docker_completion,
network_completion, network_completion,
@ -532,6 +536,13 @@ def add_environment_remote(
help=suppress or 'remote provider to use: %(choices)s', help=suppress or 'remote provider to use: %(choices)s',
) )
environments_parser.add_argument(
'--remote-arch',
metavar='ARCH',
choices=REMOTE_ARCHITECTURES,
help=suppress or 'remote arch to use: %(choices)s',
)
def complete_remote_stage(prefix: str, **_) -> t.List[str]: def complete_remote_stage(prefix: str, **_) -> t.List[str]:
"""Return a list of supported stages matching the given prefix.""" """Return a list of supported stages matching the given prefix."""

@ -10,6 +10,10 @@ from ...constants import (
SUPPORTED_PYTHON_VERSIONS, SUPPORTED_PYTHON_VERSIONS,
) )
from ...util import (
REMOTE_ARCHITECTURES,
)
from ...host_configs import ( from ...host_configs import (
OriginConfig, OriginConfig,
) )
@ -126,6 +130,7 @@ class PosixRemoteKeyValueParser(KeyValueParser):
"""Return a dictionary of key names and value parsers.""" """Return a dictionary of key names and value parsers."""
return dict( return dict(
provider=ChoicesParser(REMOTE_PROVIDERS), provider=ChoicesParser(REMOTE_PROVIDERS),
arch=ChoicesParser(REMOTE_ARCHITECTURES),
python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default), python=PythonParser(versions=self.versions, allow_venv=False, allow_default=self.allow_default),
) )
@ -137,6 +142,7 @@ class PosixRemoteKeyValueParser(KeyValueParser):
state.sections[f'{"controller" if self.controller else "target"} {section_name} (comma separated):'] = '\n'.join([ state.sections[f'{"controller" if self.controller else "target"} {section_name} (comma separated):'] = '\n'.join([
f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}', f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}',
f' arch={ChoicesParser(REMOTE_ARCHITECTURES).document(state)}',
f' python={python_parser.document(state)}', f' python={python_parser.document(state)}',
]) ])
@ -149,6 +155,7 @@ class WindowsRemoteKeyValueParser(KeyValueParser):
"""Return a dictionary of key names and value parsers.""" """Return a dictionary of key names and value parsers."""
return dict( return dict(
provider=ChoicesParser(REMOTE_PROVIDERS), provider=ChoicesParser(REMOTE_PROVIDERS),
arch=ChoicesParser(REMOTE_ARCHITECTURES),
) )
def document(self, state): # type: (DocumentationState) -> t.Optional[str] def document(self, state): # type: (DocumentationState) -> t.Optional[str]
@ -157,6 +164,7 @@ class WindowsRemoteKeyValueParser(KeyValueParser):
state.sections[f'target {section_name} (comma separated):'] = '\n'.join([ state.sections[f'target {section_name} (comma separated):'] = '\n'.join([
f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}', f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}',
f' arch={ChoicesParser(REMOTE_ARCHITECTURES).document(state)}',
]) ])
return f'{{{section_name}}}' return f'{{{section_name}}}'
@ -168,6 +176,7 @@ class NetworkRemoteKeyValueParser(KeyValueParser):
"""Return a dictionary of key names and value parsers.""" """Return a dictionary of key names and value parsers."""
return dict( return dict(
provider=ChoicesParser(REMOTE_PROVIDERS), provider=ChoicesParser(REMOTE_PROVIDERS),
arch=ChoicesParser(REMOTE_ARCHITECTURES),
collection=AnyParser(), collection=AnyParser(),
connection=AnyParser(), connection=AnyParser(),
) )
@ -178,7 +187,8 @@ class NetworkRemoteKeyValueParser(KeyValueParser):
state.sections[f'target {section_name} (comma separated):'] = '\n'.join([ state.sections[f'target {section_name} (comma separated):'] = '\n'.join([
f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}', f' provider={ChoicesParser(REMOTE_PROVIDERS).document(state)}',
' collection={collecton}', f' arch={ChoicesParser(REMOTE_ARCHITECTURES).document(state)}',
' collection={collection}',
' connection={connection}', ' connection={connection}',
]) ])

@ -21,6 +21,7 @@ from ....target import (
from ....core_ci import ( from ....core_ci import (
AnsibleCoreCI, AnsibleCoreCI,
CloudResource,
) )
from ....host_configs import ( from ....host_configs import (
@ -91,7 +92,7 @@ class AwsCloudProvider(CloudProvider):
def _create_ansible_core_ci(self): # type: () -> AnsibleCoreCI def _create_ansible_core_ci(self): # type: () -> AnsibleCoreCI
"""Return an AWS instance of AnsibleCoreCI.""" """Return an AWS instance of AnsibleCoreCI."""
return AnsibleCoreCI(self.args, 'aws', 'aws', 'aws', persist=False) return AnsibleCoreCI(self.args, CloudResource(platform='aws'))
class AwsCloudEnvironment(CloudEnvironment): class AwsCloudEnvironment(CloudEnvironment):

@ -19,6 +19,7 @@ from ....target import (
from ....core_ci import ( from ....core_ci import (
AnsibleCoreCI, AnsibleCoreCI,
CloudResource,
) )
from . import ( from . import (
@ -97,7 +98,7 @@ class AzureCloudProvider(CloudProvider):
def _create_ansible_core_ci(self): # type: () -> AnsibleCoreCI def _create_ansible_core_ci(self): # type: () -> AnsibleCoreCI
"""Return an Azure instance of AnsibleCoreCI.""" """Return an Azure instance of AnsibleCoreCI."""
return AnsibleCoreCI(self.args, 'azure', 'azure', 'azure', persist=False) return AnsibleCoreCI(self.args, CloudResource(platform='azure'))
class AzureCloudEnvironment(CloudEnvironment): class AzureCloudEnvironment(CloudEnvironment):

@ -18,6 +18,7 @@ from ....target import (
from ....core_ci import ( from ....core_ci import (
AnsibleCoreCI, AnsibleCoreCI,
CloudResource,
) )
from . import ( from . import (
@ -78,7 +79,7 @@ class HcloudCloudProvider(CloudProvider):
def _create_ansible_core_ci(self): # type: () -> AnsibleCoreCI def _create_ansible_core_ci(self): # type: () -> AnsibleCoreCI
"""Return a Heztner instance of AnsibleCoreCI.""" """Return a Heztner instance of AnsibleCoreCI."""
return AnsibleCoreCI(self.args, 'hetzner', 'hetzner', 'hetzner', persist=False) return AnsibleCoreCI(self.args, CloudResource(platform='hetzner'))
class HcloudCloudEnvironment(CloudEnvironment): class HcloudCloudEnvironment(CloudEnvironment):

@ -10,6 +10,7 @@ from ...config import (
from ...util import ( from ...util import (
cache, cache,
detect_architecture,
display, display,
get_type_map, get_type_map,
) )
@ -223,6 +224,14 @@ class NetworkInventoryTargetFilter(TargetFilter[NetworkInventoryConfig]):
class OriginTargetFilter(PosixTargetFilter[OriginConfig]): class OriginTargetFilter(PosixTargetFilter[OriginConfig]):
"""Target filter for localhost.""" """Target filter for localhost."""
def filter_targets(self, targets, exclude): # type: (t.List[IntegrationTarget], t.Set[str]) -> None
"""Filter the list of targets, adding any which this host profile cannot support to the provided exclude list."""
super().filter_targets(targets, exclude)
arch = detect_architecture(self.config.python.path)
if arch:
self.skip(f'skip/{arch}', f'which are not supported by {arch}', targets, exclude)
@cache @cache
@ -247,10 +256,7 @@ def get_target_filter(args, configs, controller): # type: (IntegrationConfig, t
def get_remote_skip_aliases(config): # type: (RemoteConfig) -> t.Dict[str, str] def get_remote_skip_aliases(config): # type: (RemoteConfig) -> t.Dict[str, str]
"""Return a dictionary of skip aliases and the reason why they apply.""" """Return a dictionary of skip aliases and the reason why they apply."""
if isinstance(config, PosixRemoteConfig): return get_platform_skip_aliases(config.platform, config.version, config.arch)
return get_platform_skip_aliases(config.platform, config.version, config.arch)
return get_platform_skip_aliases(config.platform, config.version, None)
def get_platform_skip_aliases(platform, version, arch): # type: (str, str, t.Optional[str]) -> t.Dict[str, str] def get_platform_skip_aliases(platform, version, arch): # type: (str, str, t.Optional[str]) -> t.Dict[str, str]

@ -79,6 +79,7 @@ class PythonCompletionConfig(PosixCompletionConfig, metaclass=abc.ABCMeta):
class RemoteCompletionConfig(CompletionConfig): class RemoteCompletionConfig(CompletionConfig):
"""Base class for completion configuration of remote environments provisioned through Ansible Core CI.""" """Base class for completion configuration of remote environments provisioned through Ansible Core CI."""
provider: t.Optional[str] = None provider: t.Optional[str] = None
arch: t.Optional[str] = None
@property @property
def platform(self): def platform(self):
@ -99,6 +100,9 @@ class RemoteCompletionConfig(CompletionConfig):
if not self.provider: if not self.provider:
raise Exception(f'Remote completion entry "{self.name}" must provide a "provider" setting.') raise Exception(f'Remote completion entry "{self.name}" must provide a "provider" setting.')
if not self.arch:
raise Exception(f'Remote completion entry "{self.name}" must provide a "arch" setting.')
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class InventoryCompletionConfig(CompletionConfig): class InventoryCompletionConfig(CompletionConfig):
@ -152,6 +156,11 @@ class NetworkRemoteCompletionConfig(RemoteCompletionConfig):
"""Configuration for remote network platforms.""" """Configuration for remote network platforms."""
collection: str = '' collection: str = ''
connection: str = '' connection: str = ''
placeholder: bool = False
def __post_init__(self):
if not self.placeholder:
super().__post_init__()
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@ -160,7 +169,8 @@ class PosixRemoteCompletionConfig(RemoteCompletionConfig, PythonCompletionConfig
placeholder: bool = False placeholder: bool = False
def __post_init__(self): def __post_init__(self):
super().__post_init__() if not self.placeholder:
super().__post_init__()
if not self.supported_pythons: if not self.supported_pythons:
if self.version and not self.placeholder: if self.version and not self.placeholder:

@ -48,29 +48,6 @@ class TerminateMode(enum.Enum):
return self.name.lower() return self.name.lower()
class ParsedRemote:
"""A parsed version of a "remote" string."""
def __init__(self, arch, platform, version): # type: (t.Optional[str], str, str) -> None
self.arch = arch
self.platform = platform
self.version = version
@staticmethod
def parse(value): # type: (str) -> t.Optional['ParsedRemote']
"""Return a ParsedRemote from the given value or None if the syntax is invalid."""
parts = value.split('/')
if len(parts) == 2:
arch = None
platform, version = parts
elif len(parts) == 3:
arch, platform, version = parts
else:
return None
return ParsedRemote(arch, platform, version)
class EnvironmentConfig(CommonConfig): class EnvironmentConfig(CommonConfig):
"""Configuration common to all commands which execute in an environment.""" """Configuration common to all commands which execute in an environment."""
def __init__(self, args, command): # type: (t.Any, str) -> None def __init__(self, args, command): # type: (t.Any, str) -> None

@ -1,6 +1,8 @@
"""Access Ansible Core CI remote services.""" """Access Ansible Core CI remote services."""
from __future__ import annotations from __future__ import annotations
import abc
import dataclasses
import json import json
import os import os
import re import re
@ -48,6 +50,65 @@ from .data import (
) )
@dataclasses.dataclass(frozen=True)
class Resource(metaclass=abc.ABCMeta):
"""Base class for Ansible Core CI resources."""
@abc.abstractmethod
def as_tuple(self) -> t.Tuple[str, str, str, str]:
"""Return the resource as a tuple of platform, version, architecture and provider."""
@abc.abstractmethod
def get_label(self) -> str:
"""Return a user-friendly label for this resource."""
@property
@abc.abstractmethod
def persist(self) -> bool:
"""True if the resource is persistent, otherwise false."""
@dataclasses.dataclass(frozen=True)
class VmResource(Resource):
"""Details needed to request a VM from Ansible Core CI."""
platform: str
version: str
architecture: str
provider: str
tag: str
def as_tuple(self) -> t.Tuple[str, str, str, str]:
"""Return the resource as a tuple of platform, version, architecture and provider."""
return self.platform, self.version, self.architecture, self.provider
def get_label(self) -> str:
"""Return a user-friendly label for this resource."""
return f'{self.platform} {self.version} ({self.architecture}) [{self.tag}] @{self.provider}'
@property
def persist(self) -> bool:
"""True if the resource is persistent, otherwise false."""
return True
@dataclasses.dataclass(frozen=True)
class CloudResource(Resource):
"""Details needed to request cloud credentials from Ansible Core CI."""
platform: str
def as_tuple(self) -> t.Tuple[str, str, str, str]:
"""Return the resource as a tuple of platform, version, architecture and provider."""
return self.platform, '', '', self.platform
def get_label(self) -> str:
"""Return a user-friendly label for this resource."""
return self.platform
@property
def persist(self) -> bool:
"""True if the resource is persistent, otherwise false."""
return False
class AnsibleCoreCI: class AnsibleCoreCI:
"""Client for Ansible Core CI services.""" """Client for Ansible Core CI services."""
DEFAULT_ENDPOINT = 'https://ansible-core-ci.testing.ansible.com' DEFAULT_ENDPOINT = 'https://ansible-core-ci.testing.ansible.com'
@ -55,16 +116,12 @@ class AnsibleCoreCI:
def __init__( def __init__(
self, self,
args, # type: EnvironmentConfig args, # type: EnvironmentConfig
platform, # type: str resource, # type: Resource
version, # type: str
provider, # type: str
persist=True, # type: bool
load=True, # type: bool load=True, # type: bool
suffix=None, # type: t.Optional[str]
): # type: (...) -> None ): # type: (...) -> None
self.args = args self.args = args
self.platform = platform self.resource = resource
self.version = version self.platform, self.version, self.arch, self.provider = self.resource.as_tuple()
self.stage = args.remote_stage self.stage = args.remote_stage
self.client = HttpClient(args) self.client = HttpClient(args)
self.connection = None self.connection = None
@ -73,35 +130,33 @@ class AnsibleCoreCI:
self.default_endpoint = args.remote_endpoint or self.DEFAULT_ENDPOINT self.default_endpoint = args.remote_endpoint or self.DEFAULT_ENDPOINT
self.retries = 3 self.retries = 3
self.ci_provider = get_ci_provider() self.ci_provider = get_ci_provider()
self.provider = provider self.label = self.resource.get_label()
self.name = '%s-%s' % (self.platform, self.version)
if suffix: stripped_label = re.sub('[^A-Za-z0-9_.]+', '-', self.label).strip('-')
self.name += '-' + suffix
self.path = os.path.expanduser('~/.ansible/test/instances/%s-%s-%s' % (self.name, self.provider, self.stage)) self.name = f"{stripped_label}-{self.stage}" # turn the label into something suitable for use as a filename
self.path = os.path.expanduser(f'~/.ansible/test/instances/{self.name}')
self.ssh_key = SshKey(args) self.ssh_key = SshKey(args)
if persist and load and self._load(): if self.resource.persist and load and self._load():
try: try:
display.info('Checking existing %s/%s instance %s.' % (self.platform, self.version, self.instance_id), display.info(f'Checking existing {self.label} instance using: {self._uri}', verbosity=1)
verbosity=1)
self.connection = self.get(always_raise_on=[404]) self.connection = self.get(always_raise_on=[404])
display.info('Loaded existing %s/%s from: %s' % (self.platform, self.version, self._uri), verbosity=1) display.info(f'Loaded existing {self.label} instance.', verbosity=1)
except HttpError as ex: except HttpError as ex:
if ex.status != 404: if ex.status != 404:
raise raise
self._clear() self._clear()
display.info('Cleared stale %s/%s instance %s.' % (self.platform, self.version, self.instance_id), display.info(f'Cleared stale {self.label} instance.', verbosity=1)
verbosity=1)
self.instance_id = None self.instance_id = None
self.endpoint = None self.endpoint = None
elif not persist: elif not self.resource.persist:
self.instance_id = None self.instance_id = None
self.endpoint = None self.endpoint = None
self._clear() self._clear()
@ -126,8 +181,7 @@ class AnsibleCoreCI:
def start(self): def start(self):
"""Start instance.""" """Start instance."""
if self.started: if self.started:
display.info('Skipping started %s/%s instance %s.' % (self.platform, self.version, self.instance_id), display.info(f'Skipping started {self.label} instance.', verbosity=1)
verbosity=1)
return None return None
return self._start(self.ci_provider.prepare_core_ci_auth()) return self._start(self.ci_provider.prepare_core_ci_auth())
@ -135,22 +189,19 @@ class AnsibleCoreCI:
def stop(self): def stop(self):
"""Stop instance.""" """Stop instance."""
if not self.started: if not self.started:
display.info('Skipping invalid %s/%s instance %s.' % (self.platform, self.version, self.instance_id), display.info(f'Skipping invalid {self.label} instance.', verbosity=1)
verbosity=1)
return return
response = self.client.delete(self._uri) response = self.client.delete(self._uri)
if response.status_code == 404: if response.status_code == 404:
self._clear() self._clear()
display.info('Cleared invalid %s/%s instance %s.' % (self.platform, self.version, self.instance_id), display.info(f'Cleared invalid {self.label} instance.', verbosity=1)
verbosity=1)
return return
if response.status_code == 200: if response.status_code == 200:
self._clear() self._clear()
display.info('Stopped running %s/%s instance %s.' % (self.platform, self.version, self.instance_id), display.info(f'Stopped running {self.label} instance.', verbosity=1)
verbosity=1)
return return
raise self._create_http_error(response) raise self._create_http_error(response)
@ -158,8 +209,7 @@ class AnsibleCoreCI:
def get(self, tries=3, sleep=15, always_raise_on=None): # type: (int, int, t.Optional[t.List[int]]) -> t.Optional[InstanceConnection] def get(self, tries=3, sleep=15, always_raise_on=None): # type: (int, int, t.Optional[t.List[int]]) -> t.Optional[InstanceConnection]
"""Get instance connection information.""" """Get instance connection information."""
if not self.started: if not self.started:
display.info('Skipping invalid %s/%s instance %s.' % (self.platform, self.version, self.instance_id), display.info(f'Skipping invalid {self.label} instance.', verbosity=1)
verbosity=1)
return None return None
if not always_raise_on: if not always_raise_on:
@ -180,7 +230,7 @@ class AnsibleCoreCI:
if not tries or response.status_code in always_raise_on: if not tries or response.status_code in always_raise_on:
raise error raise error
display.warning('%s. Trying again after %d seconds.' % (error, sleep)) display.warning(f'{error}. Trying again after {sleep} seconds.')
time.sleep(sleep) time.sleep(sleep)
if self.args.explain: if self.args.explain:
@ -216,9 +266,7 @@ class AnsibleCoreCI:
status = 'running' if self.connection.running else 'starting' status = 'running' if self.connection.running else 'starting'
display.info('Status update: %s/%s on instance %s is %s.' % display.info(f'The {self.label} instance is {status}.', verbosity=1)
(self.platform, self.version, self.instance_id, status),
verbosity=1)
return self.connection return self.connection
@ -229,16 +277,15 @@ class AnsibleCoreCI:
return return
time.sleep(10) time.sleep(10)
raise ApplicationError('Timeout waiting for %s/%s instance %s.' % raise ApplicationError(f'Timeout waiting for {self.label} instance.')
(self.platform, self.version, self.instance_id))
@property @property
def _uri(self): def _uri(self):
return '%s/%s/%s/%s' % (self.endpoint, self.stage, self.provider, self.instance_id) return f'{self.endpoint}/{self.stage}/{self.provider}/{self.instance_id}'
def _start(self, auth): def _start(self, auth):
"""Start instance.""" """Start instance."""
display.info('Initializing new %s/%s instance %s.' % (self.platform, self.version, self.instance_id), verbosity=1) display.info(f'Initializing new {self.label} instance using: {self._uri}', verbosity=1)
if self.platform == 'windows': if self.platform == 'windows':
winrm_config = read_text_file(os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'setup', 'ConfigureRemotingForAnsible.ps1')) winrm_config = read_text_file(os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'setup', 'ConfigureRemotingForAnsible.ps1'))
@ -249,6 +296,7 @@ class AnsibleCoreCI:
config=dict( config=dict(
platform=self.platform, platform=self.platform,
version=self.version, version=self.version,
architecture=self.arch,
public_key=self.ssh_key.pub_contents, public_key=self.ssh_key.pub_contents,
query=False, query=False,
winrm_config=winrm_config, winrm_config=winrm_config,
@ -266,7 +314,7 @@ class AnsibleCoreCI:
self.started = True self.started = True
self._save() self._save()
display.info('Started %s/%s from: %s' % (self.platform, self.version, self._uri), verbosity=1) display.info(f'Started {self.label} instance.', verbosity=1)
if self.args.explain: if self.args.explain:
return {} return {}
@ -277,8 +325,6 @@ class AnsibleCoreCI:
tries = self.retries tries = self.retries
sleep = 15 sleep = 15
display.info('Trying endpoint: %s' % self.endpoint, verbosity=1)
while True: while True:
tries -= 1 tries -= 1
response = self.client.put(self._uri, data=json.dumps(data), headers=headers) response = self.client.put(self._uri, data=json.dumps(data), headers=headers)
@ -294,7 +340,7 @@ class AnsibleCoreCI:
if not tries: if not tries:
raise error raise error
display.warning('%s. Trying again after %d seconds.' % (error, sleep)) display.warning(f'{error}. Trying again after {sleep} seconds.')
time.sleep(sleep) time.sleep(sleep)
def _clear(self): def _clear(self):
@ -345,14 +391,14 @@ class AnsibleCoreCI:
def save(self): # type: () -> t.Dict[str, str] def save(self): # type: () -> t.Dict[str, str]
"""Save instance details and return as a dictionary.""" """Save instance details and return as a dictionary."""
return dict( return dict(
platform_version='%s/%s' % (self.platform, self.version), label=self.resource.get_label(),
instance_id=self.instance_id, instance_id=self.instance_id,
endpoint=self.endpoint, endpoint=self.endpoint,
) )
@staticmethod @staticmethod
def _create_http_error(response): # type: (HttpResponse) -> ApplicationError def _create_http_error(response): # type: (HttpResponse) -> ApplicationError
"""Return an exception created from the given HTTP resposne.""" """Return an exception created from the given HTTP response."""
response_json = response.json() response_json = response.json()
stack_trace = '' stack_trace = ''
@ -369,7 +415,7 @@ class AnsibleCoreCI:
traceback_lines = traceback.format_list(traceback_lines) traceback_lines = traceback.format_list(traceback_lines)
trace = '\n'.join([x.rstrip() for x in traceback_lines]) trace = '\n'.join([x.rstrip() for x in traceback_lines])
stack_trace = ('\nTraceback (from remote server):\n%s' % trace) stack_trace = f'\nTraceback (from remote server):\n{trace}'
else: else:
message = str(response_json) message = str(response_json)
@ -379,7 +425,7 @@ class AnsibleCoreCI:
class CoreHttpError(HttpError): class CoreHttpError(HttpError):
"""HTTP response as an error.""" """HTTP response as an error."""
def __init__(self, status, remote_message, remote_stack_trace): # type: (int, str, str) -> None def __init__(self, status, remote_message, remote_stack_trace): # type: (int, str, str) -> None
super().__init__(status, '%s%s' % (remote_message, remote_stack_trace)) super().__init__(status, f'{remote_message}{remote_stack_trace}')
self.remote_message = remote_message self.remote_message = remote_message
self.remote_stack_trace = remote_stack_trace self.remote_stack_trace = remote_stack_trace
@ -388,8 +434,8 @@ class CoreHttpError(HttpError):
class SshKey: class SshKey:
"""Container for SSH key used to connect to remote instances.""" """Container for SSH key used to connect to remote instances."""
KEY_TYPE = 'rsa' # RSA is used to maintain compatibility with paramiko and EC2 KEY_TYPE = 'rsa' # RSA is used to maintain compatibility with paramiko and EC2
KEY_NAME = 'id_%s' % KEY_TYPE KEY_NAME = f'id_{KEY_TYPE}'
PUB_NAME = '%s.pub' % KEY_NAME PUB_NAME = f'{KEY_NAME}.pub'
@mutex @mutex
def __init__(self, args): # type: (EnvironmentConfig) -> None def __init__(self, args): # type: (EnvironmentConfig) -> None
@ -502,6 +548,6 @@ class InstanceConnection:
def __str__(self): def __str__(self):
if self.password: if self.password:
return '%s:%s [%s:%s]' % (self.hostname, self.port, self.username, self.password) return f'{self.hostname}:{self.port} [{self.username}:{self.password}]'
return '%s:%s [%s]' % (self.hostname, self.port, self.username) return f'{self.hostname}:{self.port} [{self.username}]'

@ -39,6 +39,7 @@ from .util import (
get_available_python_versions, get_available_python_versions,
str_to_version, str_to_version,
version_to_str, version_to_str,
Architecture,
) )
@ -206,6 +207,7 @@ class RemoteConfig(HostConfig, metaclass=abc.ABCMeta):
"""Base class for remote host configuration.""" """Base class for remote host configuration."""
name: t.Optional[str] = None name: t.Optional[str] = None
provider: t.Optional[str] = None provider: t.Optional[str] = None
arch: t.Optional[str] = None
@property @property
def platform(self): # type: () -> str def platform(self): # type: () -> str
@ -227,6 +229,7 @@ class RemoteConfig(HostConfig, metaclass=abc.ABCMeta):
self.provider = None self.provider = None
self.provider = self.provider or defaults.provider or 'aws' self.provider = self.provider or defaults.provider or 'aws'
self.arch = self.arch or defaults.arch or Architecture.X86_64
@property @property
def is_managed(self): # type: () -> bool def is_managed(self): # type: () -> bool
@ -330,8 +333,6 @@ class DockerConfig(ControllerHostConfig, PosixConfig):
@dataclasses.dataclass @dataclasses.dataclass
class PosixRemoteConfig(RemoteConfig, ControllerHostConfig, PosixConfig): class PosixRemoteConfig(RemoteConfig, ControllerHostConfig, PosixConfig):
"""Configuration for a POSIX remote host.""" """Configuration for a POSIX remote host."""
arch: t.Optional[str] = None
def get_defaults(self, context): # type: (HostContext) -> PosixRemoteCompletionConfig def get_defaults(self, context): # type: (HostContext) -> PosixRemoteCompletionConfig
"""Return the default settings.""" """Return the default settings."""
return filter_completion(remote_completion()).get(self.name) or remote_completion().get(self.platform) or PosixRemoteCompletionConfig( return filter_completion(remote_completion()).get(self.name) or remote_completion().get(self.platform) or PosixRemoteCompletionConfig(
@ -388,6 +389,7 @@ class NetworkRemoteConfig(RemoteConfig, NetworkConfig):
"""Return the default settings.""" """Return the default settings."""
return filter_completion(network_completion()).get(self.name) or NetworkRemoteCompletionConfig( return filter_completion(network_completion()).get(self.name) or NetworkRemoteCompletionConfig(
name=self.name, name=self.name,
placeholder=True,
) )
def apply_defaults(self, context, defaults): # type: (HostContext, CompletionConfig) -> None def apply_defaults(self, context, defaults): # type: (HostContext, CompletionConfig) -> None

@ -40,6 +40,7 @@ from .host_configs import (
from .core_ci import ( from .core_ci import (
AnsibleCoreCI, AnsibleCoreCI,
SshKey, SshKey,
VmResource,
) )
from .util import ( from .util import (
@ -50,6 +51,7 @@ from .util import (
get_type_map, get_type_map,
sanitize_host_name, sanitize_host_name,
sorted_versions, sorted_versions,
InternalError,
) )
from .util_common import ( from .util_common import (
@ -295,12 +297,18 @@ class RemoteProfile(SshTargetHostProfile[TRemoteConfig], metaclass=abc.ABCMeta):
def create_core_ci(self, load): # type: (bool) -> AnsibleCoreCI def create_core_ci(self, load): # type: (bool) -> AnsibleCoreCI
"""Create and return an AnsibleCoreCI instance.""" """Create and return an AnsibleCoreCI instance."""
if not self.config.arch:
raise InternalError(f'No arch specified for config: {self.config}')
return AnsibleCoreCI( return AnsibleCoreCI(
args=self.args, args=self.args,
platform=self.config.platform, resource=VmResource(
version=self.config.version, platform=self.config.platform,
provider=self.config.provider, version=self.config.version,
suffix='controller' if self.controller else 'target', architecture=self.config.arch,
provider=self.config.provider,
tag='controller' if self.controller else 'target',
),
load=load, load=load,
) )

@ -6,8 +6,10 @@ import errno
import fcntl import fcntl
import importlib.util import importlib.util
import inspect import inspect
import json
import keyword import keyword
import os import os
import platform
import pkgutil import pkgutil
import random import random
import re import re
@ -98,6 +100,18 @@ MODE_DIRECTORY = MODE_READ | stat.S_IWUSR | stat.S_IXUSR | stat.S_IXGRP | stat.S
MODE_DIRECTORY_WRITE = MODE_DIRECTORY | stat.S_IWGRP | stat.S_IWOTH MODE_DIRECTORY_WRITE = MODE_DIRECTORY | stat.S_IWGRP | stat.S_IWOTH
class Architecture:
"""
Normalized architecture names.
These are the architectures supported by ansible-test, such as when provisioning remote instances.
"""
X86_64 = 'x86_64'
AARCH64 = 'aarch64'
REMOTE_ARCHITECTURES = list(value for key, value in Architecture.__dict__.items() if not key.startswith('__'))
def is_valid_identifier(value: str) -> bool: def is_valid_identifier(value: str) -> bool:
"""Return True if the given value is a valid non-keyword Python identifier, otherwise return False.""" """Return True if the given value is a valid non-keyword Python identifier, otherwise return False."""
return value.isidentifier() and not keyword.iskeyword(value) return value.isidentifier() and not keyword.iskeyword(value)
@ -121,6 +135,58 @@ def cache(func): # type: (t.Callable[[], TValue]) -> t.Callable[[], TValue]
return wrapper return wrapper
@mutex
def detect_architecture(python: str) -> t.Optional[str]:
"""Detect the architecture of the specified Python and return a normalized version, or None if it cannot be determined."""
results: t.Dict[str, t.Optional[str]]
try:
results = detect_architecture.results # type: ignore[attr-defined]
except AttributeError:
results = detect_architecture.results = {} # type: ignore[attr-defined]
if python in results:
return results[python]
if python == sys.executable or os.path.realpath(python) == os.path.realpath(sys.executable):
uname = platform.uname()
else:
data = raw_command([python, '-c', 'import json, platform; print(json.dumps(platform.uname()));'], capture=True)[0]
uname = json.loads(data)
translation = {
'x86_64': Architecture.X86_64, # Linux, macOS
'amd64': Architecture.X86_64, # FreeBSD
'aarch64': Architecture.AARCH64, # Linux, FreeBSD
'arm64': Architecture.AARCH64, # FreeBSD
}
candidates = []
if len(uname) >= 5:
candidates.append(uname[4])
if len(uname) >= 6:
candidates.append(uname[5])
candidates = sorted(set(candidates))
architectures = sorted(set(arch for arch in [translation.get(candidate) for candidate in candidates] if arch))
architecture: t.Optional[str] = None
if not architectures:
display.warning(f'Unable to determine architecture for Python interpreter "{python}" from: {candidates}')
elif len(architectures) == 1:
architecture = architectures[0]
display.info(f'Detected architecture {architecture} for Python interpreter: {python}', verbosity=1)
else:
display.warning(f'Conflicting architectures detected ({architectures}) for Python interpreter "{python}" from: {candidates}')
results[python] = architecture
return architecture
def filter_args(args, filters): # type: (t.List[str], t.Dict[str, int]) -> t.List[str] def filter_args(args, filters): # type: (t.List[str], t.Dict[str, int]) -> t.List[str]
"""Return a filtered version of the given command line arguments.""" """Return a filtered version of the given command line arguments."""
remaining = 0 remaining = 0

Loading…
Cancel
Save