ansible-core 2.19.0b5__py3-none-any.whl → 2.19.0b7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. ansible/_internal/_ansiballz/__init__.py +0 -0
  2. ansible/_internal/_ansiballz/_builder.py +101 -0
  3. ansible/_internal/{_ansiballz.py → _ansiballz/_wrapper.py} +11 -11
  4. ansible/_internal/_templating/_jinja_bits.py +22 -4
  5. ansible/_internal/_templating/_jinja_common.py +1 -1
  6. ansible/_internal/_templating/_jinja_plugins.py +5 -2
  7. ansible/_internal/_templating/_template_vars.py +72 -0
  8. ansible/_internal/_templating/_transform.py +6 -0
  9. ansible/_internal/_yaml/_constructor.py +4 -4
  10. ansible/_internal/_yaml/_dumper.py +26 -18
  11. ansible/cli/__init__.py +9 -14
  12. ansible/cli/adhoc.py +6 -3
  13. ansible/cli/arguments/option_helpers.py +1 -1
  14. ansible/cli/console.py +2 -2
  15. ansible/cli/doc.py +4 -4
  16. ansible/cli/inventory.py +5 -7
  17. ansible/config/base.yml +33 -6
  18. ansible/errors/__init__.py +2 -1
  19. ansible/executor/module_common.py +75 -44
  20. ansible/executor/powershell/psrp_put_file.ps1 +1 -1
  21. ansible/executor/process/worker.py +2 -2
  22. ansible/executor/task_executor.py +2 -2
  23. ansible/executor/task_queue_manager.py +34 -70
  24. ansible/executor/task_result.py +1 -1
  25. ansible/galaxy/api.py +3 -6
  26. ansible/galaxy/collection/__init__.py +1 -6
  27. ansible/galaxy/collection/concrete_artifact_manager.py +4 -10
  28. ansible/galaxy/dependency_resolution/providers.py +3 -3
  29. ansible/galaxy/role.py +2 -2
  30. ansible/inventory/group.py +6 -1
  31. ansible/inventory/host.py +6 -1
  32. ansible/module_utils/_internal/__init__.py +7 -4
  33. ansible/module_utils/_internal/_ansiballz/__init__.py +0 -0
  34. ansible/module_utils/_internal/_ansiballz/_extensions/__init__.py +0 -0
  35. ansible/module_utils/_internal/_ansiballz/_extensions/_coverage.py +45 -0
  36. ansible/module_utils/_internal/_ansiballz/_extensions/_pydevd.py +62 -0
  37. ansible/module_utils/_internal/{_ansiballz.py → _ansiballz/_loader.py} +10 -38
  38. ansible/module_utils/_internal/_ansiballz/_respawn.py +32 -0
  39. ansible/module_utils/_internal/_ansiballz/_respawn_wrapper.py +23 -0
  40. ansible/module_utils/_internal/_datatag/__init__.py +23 -1
  41. ansible/module_utils/_internal/_deprecator.py +39 -34
  42. ansible/module_utils/_internal/_json/_profiles/__init__.py +1 -0
  43. ansible/module_utils/_internal/_messages.py +26 -2
  44. ansible/module_utils/_internal/_plugin_info.py +14 -1
  45. ansible/module_utils/ansible_release.py +1 -1
  46. ansible/module_utils/basic.py +58 -70
  47. ansible/module_utils/common/respawn.py +4 -41
  48. ansible/module_utils/common/yaml.py +1 -1
  49. ansible/module_utils/connection.py +8 -11
  50. ansible/module_utils/csharp/Ansible.Basic.cs +1 -1
  51. ansible/module_utils/csharp/Ansible.Privilege.cs +2 -2
  52. ansible/module_utils/facts/hardware/base.py +1 -1
  53. ansible/module_utils/facts/hardware/linux.py +1 -1
  54. ansible/module_utils/facts/other/facter.py +1 -1
  55. ansible/module_utils/facts/sysctl.py +4 -6
  56. ansible/module_utils/facts/system/caps.py +2 -2
  57. ansible/module_utils/facts/system/distribution.py +2 -2
  58. ansible/module_utils/facts/system/local.py +1 -1
  59. ansible/module_utils/facts/virtual/linux.py +1 -1
  60. ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 +1 -1
  61. ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 +1 -1
  62. ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 +1 -1
  63. ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 +1 -1
  64. ansible/module_utils/service.py +1 -1
  65. ansible/module_utils/urls.py +5 -5
  66. ansible/modules/apt.py +9 -3
  67. ansible/modules/apt_repository.py +10 -10
  68. ansible/modules/assemble.py +7 -5
  69. ansible/modules/async_wrapper.py +7 -17
  70. ansible/modules/command.py +3 -3
  71. ansible/modules/copy.py +4 -4
  72. ansible/modules/cron.py +1 -1
  73. ansible/modules/expect.py +5 -5
  74. ansible/modules/file.py +16 -17
  75. ansible/modules/find.py +3 -3
  76. ansible/modules/get_url.py +17 -0
  77. ansible/modules/git.py +9 -7
  78. ansible/modules/hostname.py +2 -2
  79. ansible/modules/known_hosts.py +12 -14
  80. ansible/modules/package.py +6 -0
  81. ansible/modules/pip.py +9 -11
  82. ansible/modules/raw.py +2 -2
  83. ansible/modules/replace.py +2 -2
  84. ansible/modules/slurp.py +10 -13
  85. ansible/modules/stat.py +6 -8
  86. ansible/modules/unarchive.py +6 -6
  87. ansible/modules/user.py +1 -1
  88. ansible/modules/wait_for.py +38 -33
  89. ansible/modules/yum_repository.py +4 -3
  90. ansible/parsing/dataloader.py +2 -2
  91. ansible/parsing/mod_args.py +38 -20
  92. ansible/parsing/vault/__init__.py +9 -13
  93. ansible/playbook/base.py +7 -4
  94. ansible/playbook/helpers.py +1 -1
  95. ansible/playbook/included_file.py +3 -1
  96. ansible/playbook/play_context.py +2 -0
  97. ansible/playbook/playbook_include.py +23 -56
  98. ansible/playbook/role/__init__.py +38 -21
  99. ansible/playbook/taggable.py +19 -5
  100. ansible/playbook/task.py +2 -0
  101. ansible/plugins/action/__init__.py +2 -2
  102. ansible/plugins/action/assemble.py +2 -1
  103. ansible/plugins/action/assert.py +2 -2
  104. ansible/plugins/action/fetch.py +3 -3
  105. ansible/plugins/action/script.py +5 -4
  106. ansible/plugins/action/template.py +9 -3
  107. ansible/plugins/cache/__init__.py +17 -19
  108. ansible/plugins/callback/__init__.py +77 -87
  109. ansible/plugins/callback/default.py +0 -3
  110. ansible/plugins/callback/junit.py +0 -6
  111. ansible/plugins/callback/tree.py +5 -5
  112. ansible/plugins/connection/local.py +4 -4
  113. ansible/plugins/connection/paramiko_ssh.py +5 -5
  114. ansible/plugins/connection/ssh.py +9 -7
  115. ansible/plugins/connection/winrm.py +1 -1
  116. ansible/plugins/filter/core.py +19 -21
  117. ansible/plugins/filter/encryption.py +10 -2
  118. ansible/plugins/filter/pow.yml +1 -1
  119. ansible/plugins/filter/root.yml +1 -1
  120. ansible/plugins/filter/strftime.yml +3 -3
  121. ansible/plugins/filter/to_uuid.yml +1 -1
  122. ansible/plugins/inventory/script.py +1 -1
  123. ansible/plugins/list.py +5 -4
  124. ansible/plugins/loader.py +5 -0
  125. ansible/plugins/lookup/password.py +4 -6
  126. ansible/plugins/lookup/template.py +9 -4
  127. ansible/plugins/shell/powershell.py +3 -2
  128. ansible/plugins/shell/sh.py +3 -2
  129. ansible/plugins/strategy/__init__.py +3 -3
  130. ansible/plugins/test/core.py +2 -2
  131. ansible/release.py +1 -1
  132. ansible/template/__init__.py +9 -53
  133. ansible/utils/collection_loader/_collection_finder.py +3 -3
  134. ansible/utils/display.py +38 -37
  135. ansible/utils/galaxy.py +2 -2
  136. ansible/utils/hashing.py +6 -7
  137. ansible/utils/path.py +6 -8
  138. ansible/utils/py3compat.py +2 -1
  139. ansible/utils/ssh_functions.py +3 -2
  140. ansible/utils/vars.py +4 -1
  141. ansible/vars/manager.py +6 -3
  142. ansible/vars/plugins.py +3 -3
  143. ansible/vars/reserved.py +6 -4
  144. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/METADATA +1 -1
  145. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/RECORD +184 -173
  146. ansible_test/_internal/__init__.py +5 -0
  147. ansible_test/_internal/ansible_util.py +1 -1
  148. ansible_test/_internal/classification/python.py +6 -0
  149. ansible_test/_internal/cli/commands/__init__.py +0 -5
  150. ansible_test/_internal/cli/environments.py +51 -5
  151. ansible_test/_internal/commands/coverage/__init__.py +1 -1
  152. ansible_test/_internal/commands/integration/__init__.py +18 -5
  153. ansible_test/_internal/commands/integration/cloud/httptester.py +1 -1
  154. ansible_test/_internal/commands/integration/coverage.py +7 -2
  155. ansible_test/_internal/commands/sanity/__init__.py +3 -1
  156. ansible_test/_internal/commands/sanity/integration_aliases.py +11 -0
  157. ansible_test/_internal/commands/shell/__init__.py +43 -4
  158. ansible_test/_internal/commands/units/__init__.py +4 -1
  159. ansible_test/_internal/config.py +21 -13
  160. ansible_test/_internal/debugging.py +166 -0
  161. ansible_test/_internal/delegation.py +21 -13
  162. ansible_test/_internal/host_profiles.py +259 -16
  163. ansible_test/_internal/inventory.py +4 -0
  164. ansible_test/_internal/metadata.py +94 -4
  165. ansible_test/_internal/processes.py +80 -0
  166. ansible_test/_internal/provisioning.py +10 -4
  167. ansible_test/_internal/python_requirements.py +27 -0
  168. ansible_test/_internal/ssh.py +1 -5
  169. ansible_test/_internal/target.py +8 -0
  170. ansible_test/_internal/thread.py +2 -1
  171. ansible_test/_internal/timeout.py +1 -1
  172. ansible_test/_internal/util.py +20 -12
  173. ansible_test/_internal/util_common.py +13 -3
  174. ansible_test/_util/target/injector/python.py +8 -0
  175. ansible_test/_util/target/setup/requirements.py +3 -9
  176. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/WHEEL +0 -0
  177. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/entry_points.txt +0 -0
  178. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/COPYING +0 -0
  179. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/Apache-License.txt +0 -0
  180. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/BSD-3-Clause.txt +0 -0
  181. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/MIT-license.txt +0 -0
  182. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/PSF-license.txt +0 -0
  183. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/simplified_bsd.txt +0 -0
  184. {ansible_core-2.19.0b5.dist-info → ansible_core-2.19.0b7.dist-info}/top_level.txt +0 -0
@@ -113,23 +113,27 @@ def delegate(args: CommonConfig, host_state: HostState, exclude: list[str], requ
113
113
  assert isinstance(args, EnvironmentConfig)
114
114
 
115
115
  with delegation_context(args, host_state):
116
- if isinstance(args, TestConfig):
117
- args.metadata.ci_provider = get_ci_provider().code
116
+ args.metadata.ci_provider = get_ci_provider().code
118
117
 
119
- make_dirs(ResultType.TMP.path)
118
+ make_dirs(ResultType.TMP.path)
120
119
 
121
- with tempfile.NamedTemporaryFile(prefix='metadata-', suffix='.json', dir=ResultType.TMP.path) as metadata_fd:
122
- args.metadata_path = os.path.join(ResultType.TMP.relative_path, os.path.basename(metadata_fd.name))
123
- args.metadata.to_file(args.metadata_path)
124
-
125
- try:
126
- delegate_command(args, host_state, exclude, require)
127
- finally:
128
- args.metadata_path = None
129
- else:
120
+ with metadata_context(args):
130
121
  delegate_command(args, host_state, exclude, require)
131
122
 
132
123
 
124
+ @contextlib.contextmanager
125
+ def metadata_context(args: EnvironmentConfig) -> t.Generator[None]:
126
+ """A context manager which exports delegation metadata."""
127
+ with tempfile.NamedTemporaryFile(prefix='metadata-', suffix='.json', dir=ResultType.TMP.path) as metadata_fd:
128
+ args.metadata_path = os.path.join(ResultType.TMP.relative_path, os.path.basename(metadata_fd.name))
129
+ args.metadata.to_file(args.metadata_path)
130
+
131
+ try:
132
+ yield
133
+ finally:
134
+ args.metadata_path = None
135
+
136
+
133
137
  def delegate_command(args: EnvironmentConfig, host_state: HostState, exclude: list[str], require: list[str]) -> None:
134
138
  """Delegate execution based on the provided host state."""
135
139
  con = host_state.controller_profile.get_origin_controller_connection()
@@ -189,6 +193,10 @@ def delegate_command(args: EnvironmentConfig, host_state: HostState, exclude: li
189
193
  networks = container.get_network_names()
190
194
 
191
195
  if networks is not None:
196
+ if args.metadata.debugger_flags.enable:
197
+ networks = []
198
+ display.warning('Skipping network isolation to enable remote debugging.')
199
+
192
200
  for network in networks:
193
201
  try:
194
202
  con.disconnect_network(network)
@@ -334,6 +342,7 @@ def filter_options(
334
342
  ('--redact', 0, False),
335
343
  ('--no-redact', 0, not args.redact),
336
344
  ('--host-path', 1, args.host_path),
345
+ ('--metadata', 1, args.metadata_path),
337
346
  ]
338
347
 
339
348
  if isinstance(args, TestConfig):
@@ -346,7 +355,6 @@ def filter_options(
346
355
  ('--ignore-unstaged', 0, False),
347
356
  ('--changed-from', 1, False),
348
357
  ('--changed-path', 1, False),
349
- ('--metadata', 1, args.metadata_path),
350
358
  ('--exclude', 1, exclude),
351
359
  ('--require', 1, require),
352
360
  ('--base-branch', 1, False),
@@ -4,7 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import abc
6
6
  import dataclasses
7
+ import importlib
8
+ import json
7
9
  import os
10
+ import pathlib
8
11
  import shlex
9
12
  import tempfile
10
13
  import time
@@ -58,6 +61,9 @@ from .util import (
58
61
  HostConnectionError,
59
62
  ANSIBLE_TEST_TARGET_ROOT,
60
63
  WINDOWS_CONNECTION_VARIABLES,
64
+ ANSIBLE_SOURCE_ROOT,
65
+ ANSIBLE_LIB_ROOT,
66
+ ANSIBLE_TEST_ROOT,
61
67
  )
62
68
 
63
69
  from .util_common import (
@@ -92,6 +98,8 @@ from .venv import (
92
98
 
93
99
  from .ssh import (
94
100
  SshConnectionDetail,
101
+ create_ssh_port_forwards,
102
+ SshProcess,
95
103
  )
96
104
 
97
105
  from .ansible_util import (
@@ -206,7 +214,7 @@ class Inventory:
206
214
  inventory_text += f'[{group}]\n'
207
215
 
208
216
  for host, variables in hosts.items():
209
- kvp = ' '.join(f'{key}="{value}"' for key, value in variables.items())
217
+ kvp = ' '.join(f"{key}={value!r}" for key, value in variables.items())
210
218
  inventory_text += f'{host} {kvp}\n'
211
219
 
212
220
  inventory_text += '\n'
@@ -235,18 +243,24 @@ class HostProfile(t.Generic[THostConfig], metaclass=abc.ABCMeta):
235
243
  *,
236
244
  args: EnvironmentConfig,
237
245
  config: THostConfig,
238
- targets: t.Optional[list[HostConfig]],
246
+ controller: ControllerHostProfile,
239
247
  ) -> None:
240
248
  self.args = args
241
249
  self.config = config
242
- self.controller = bool(targets)
243
- self.targets = targets or []
250
+ self.controller = not controller # this profile is a controller whenever the `controller` arg was not provided
251
+ self.targets = args.targets if self.controller else [] # only keep targets if this profile is a controller
252
+ self.controller_profile = controller if isinstance(self, ControllerProfile) else None
244
253
 
245
254
  self.state: dict[str, t.Any] = {}
246
255
  """State that must be persisted across delegation."""
247
256
  self.cache: dict[str, t.Any] = {}
248
257
  """Cache that must not be persisted across delegation."""
249
258
 
259
+ @property
260
+ @abc.abstractmethod
261
+ def name(self) -> str:
262
+ """The name of the host profile."""
263
+
250
264
  def provision(self) -> None:
251
265
  """Provision the host before delegation."""
252
266
 
@@ -274,6 +288,179 @@ class HostProfile(t.Generic[THostConfig], metaclass=abc.ABCMeta):
274
288
  # args will be populated after the instances are restored
275
289
  self.cache = {}
276
290
 
291
+ def __str__(self) -> str:
292
+ return f'{self.__class__.__name__}: {self.name}'
293
+
294
+
295
+ class DebuggableProfile(HostProfile[THostConfig], metaclass=abc.ABCMeta):
296
+ """Base class for profiles remote debugging."""
297
+
298
+ __PYDEVD_PORT_KEY = 'pydevd_port'
299
+ __DEBUGGING_FORWARDER_KEY = 'debugging_forwarder'
300
+
301
+ @property
302
+ def debugging_enabled(self) -> bool:
303
+ """Returns `True` if debugging is enabled for this profile, otherwise `False`."""
304
+ if self.controller:
305
+ return self.args.metadata.debugger_flags.enable
306
+
307
+ return self.args.metadata.debugger_flags.ansiballz
308
+
309
+ @property
310
+ def pydevd_port(self) -> int:
311
+ """The pydevd port to use."""
312
+ return self.state.get(self.__PYDEVD_PORT_KEY) or self.origin_pydev_port
313
+
314
+ @property
315
+ def debugging_forwarder(self) -> SshProcess | None:
316
+ """The SSH forwarding process, if enabled."""
317
+ return self.cache.get(self.__DEBUGGING_FORWARDER_KEY)
318
+
319
+ @debugging_forwarder.setter
320
+ def debugging_forwarder(self, value: SshProcess) -> None:
321
+ """The SSH forwarding process, if enabled."""
322
+ self.cache[self.__DEBUGGING_FORWARDER_KEY] = value
323
+
324
+ @property
325
+ def origin_pydev_port(self) -> int:
326
+ """The pydevd port on the origin."""
327
+ return self.args.metadata.debugger_settings.port
328
+
329
+ def enable_debugger_forwarding(self, ssh: SshConnectionDetail) -> None:
330
+ """Enable pydevd port forwarding from the origin."""
331
+ if not self.debugging_enabled:
332
+ return
333
+
334
+ endpoint = ('localhost', self.origin_pydev_port)
335
+ forwards = [endpoint]
336
+
337
+ self.debugging_forwarder = create_ssh_port_forwards(self.args, ssh, forwards)
338
+
339
+ port_forwards = self.debugging_forwarder.collect_port_forwards()
340
+
341
+ self.state[self.__PYDEVD_PORT_KEY] = port = port_forwards[endpoint]
342
+
343
+ display.info(f'Remote debugging of {self.name!r} is available on port {port}.', verbosity=1)
344
+
345
+ def deprovision(self) -> None:
346
+ """Deprovision the host after delegation has completed."""
347
+ super().deprovision()
348
+
349
+ if not self.debugging_forwarder:
350
+ return # forwarding not in use
351
+
352
+ self.debugging_forwarder.terminate()
353
+
354
+ display.info(f'Waiting for the {self.name!r} remote debugging SSH port forwarding process to terminate.', verbosity=1)
355
+
356
+ self.debugging_forwarder.wait()
357
+
358
+ def get_pydevd_settrace_arguments(self) -> dict[str, object]:
359
+ """Get settrace arguments for pydevd."""
360
+ return self.args.metadata.debugger_settings.settrace | dict(
361
+ host="localhost",
362
+ port=self.pydevd_port,
363
+ )
364
+
365
+ def get_pydevd_environment_variables(self) -> dict[str, str]:
366
+ """Get environment variables needed to configure pydevd for debugging."""
367
+ return dict(
368
+ PATHS_FROM_ECLIPSE_TO_PYTHON=json.dumps(list(self.get_source_mapping().items())),
369
+ )
370
+
371
+ def get_source_mapping(self) -> dict[str, str]:
372
+ """Get the source mapping from the given metadata."""
373
+ from . import data_context
374
+
375
+ if collection := data_context().content.collection:
376
+ source_mapping = {
377
+ f"{self.args.metadata.ansible_test_root}/": f'{ANSIBLE_TEST_ROOT}/',
378
+ f"{self.args.metadata.ansible_lib_root}/": f'{ANSIBLE_LIB_ROOT}/',
379
+ f'{self.args.metadata.collection_root}/ansible_collections/': f'{collection.root}/ansible_collections/',
380
+ }
381
+ else:
382
+ ansible_source_root = pathlib.Path(self.args.metadata.ansible_lib_root).parent.parent
383
+
384
+ source_mapping = {
385
+ f"{ansible_source_root}/": f'{ANSIBLE_SOURCE_ROOT}/',
386
+ }
387
+
388
+ source_mapping = {key: value for key, value in source_mapping.items() if key != value}
389
+
390
+ return source_mapping
391
+
392
+ def activate_debugger(self) -> None:
393
+ """Activate the debugger after delegation."""
394
+ if not self.args.metadata.loaded or not self.args.metadata.debugger_flags.self:
395
+ return
396
+
397
+ display.info('Activating remote debugging of ansible-test.', verbosity=1)
398
+
399
+ os.environ.update(self.get_pydevd_environment_variables())
400
+
401
+ debugging_module = importlib.import_module(self.args.metadata.debugger_settings.module)
402
+ debugging_module.settrace(**self.get_pydevd_settrace_arguments())
403
+
404
+ pass # pylint: disable=unnecessary-pass # when suspend is True, execution pauses here -- it's also a convenient place to put a breakpoint
405
+
406
+ def get_ansiballz_inventory_variables(self) -> dict[str, t.Any]:
407
+ """
408
+ Return inventory variables for remote debugging of AnsiballZ modules.
409
+ When delegating, this function must be called after delegation.
410
+ """
411
+ if not self.args.metadata.debugger_flags.ansiballz:
412
+ return {}
413
+
414
+ return dict(
415
+ _ansible_ansiballz_debugger_config=json.dumps(self.get_ansiballz_debugger_config()),
416
+ )
417
+
418
+ def get_ansiballz_environment_variables(self) -> dict[str, t.Any]:
419
+ """
420
+ Return environment variables for remote debugging of AnsiballZ modules.
421
+ When delegating, this function must be called after delegation.
422
+ """
423
+ if not self.args.metadata.debugger_flags.ansiballz:
424
+ return {}
425
+
426
+ return dict(
427
+ _ANSIBLE_ANSIBALLZ_DEBUGGER_CONFIG=json.dumps(self.get_ansiballz_debugger_config()),
428
+ )
429
+
430
+ def get_ansiballz_debugger_config(self) -> dict[str, t.Any]:
431
+ """
432
+ Return config for remote debugging of AnsiballZ modules.
433
+ When delegating, this function must be called after delegation.
434
+ """
435
+ debugger_config = dict(
436
+ module=self.args.metadata.debugger_settings.module,
437
+ settrace=self.get_pydevd_settrace_arguments(),
438
+ source_mapping=self.get_source_mapping(),
439
+ )
440
+
441
+ display.info(f'>>> Debugger Config ({self.name} AnsiballZ)\n{json.dumps(debugger_config, indent=4)}', verbosity=3)
442
+
443
+ return debugger_config
444
+
445
+ def get_ansible_cli_environment_variables(self) -> dict[str, t.Any]:
446
+ """
447
+ Return environment variables for remote debugging of the Ansible CLI.
448
+ When delegating, this function must be called after delegation.
449
+ """
450
+ if not self.args.metadata.debugger_flags.cli:
451
+ return {}
452
+
453
+ debugger_config = dict(
454
+ args=['-m', 'pydevd', '--client', 'localhost', '--port', str(self.pydevd_port)] + self.args.metadata.debugger_settings.args + ['--file'],
455
+ env=self.get_pydevd_environment_variables(),
456
+ )
457
+
458
+ display.info(f'>>> Debugger Config ({self.name} Ansible CLI)\n{json.dumps(debugger_config, indent=4)}', verbosity=3)
459
+
460
+ return dict(
461
+ ANSIBLE_TEST_DEBUGGER_CONFIG=json.dumps(debugger_config),
462
+ )
463
+
277
464
 
278
465
  class PosixProfile(HostProfile[TPosixConfig], metaclass=abc.ABCMeta):
279
466
  """Base class for POSIX host profiles."""
@@ -297,7 +484,7 @@ class PosixProfile(HostProfile[TPosixConfig], metaclass=abc.ABCMeta):
297
484
  return python
298
485
 
299
486
 
300
- class ControllerHostProfile(PosixProfile[TControllerHostConfig], metaclass=abc.ABCMeta):
487
+ class ControllerHostProfile(PosixProfile[TControllerHostConfig], DebuggableProfile[TControllerHostConfig], metaclass=abc.ABCMeta):
301
488
  """Base class for profiles usable as a controller."""
302
489
 
303
490
  @abc.abstractmethod
@@ -320,6 +507,11 @@ class SshTargetHostProfile(HostProfile[THostConfig], metaclass=abc.ABCMeta):
320
507
  class RemoteProfile(SshTargetHostProfile[TRemoteConfig], metaclass=abc.ABCMeta):
321
508
  """Base class for remote instance profiles."""
322
509
 
510
+ @property
511
+ def name(self) -> str:
512
+ """The name of the host profile."""
513
+ return self.config.name
514
+
323
515
  @property
324
516
  def core_ci_state(self) -> t.Optional[dict[str, str]]:
325
517
  """The saved Ansible Core CI state."""
@@ -339,6 +531,8 @@ class RemoteProfile(SshTargetHostProfile[TRemoteConfig], metaclass=abc.ABCMeta):
339
531
 
340
532
  def deprovision(self) -> None:
341
533
  """Deprovision the host after delegation has completed."""
534
+ super().deprovision()
535
+
342
536
  if self.args.remote_terminate == TerminateMode.ALWAYS or (self.args.remote_terminate == TerminateMode.SUCCESS and self.args.success):
343
537
  self.delete_instance()
344
538
 
@@ -394,9 +588,19 @@ class RemoteProfile(SshTargetHostProfile[TRemoteConfig], metaclass=abc.ABCMeta):
394
588
  )
395
589
 
396
590
 
397
- class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[ControllerConfig]):
591
+ class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[ControllerConfig], DebuggableProfile[ControllerConfig]):
398
592
  """Host profile for the controller as a target."""
399
593
 
594
+ @property
595
+ def name(self) -> str:
596
+ """The name of the host profile."""
597
+ return self.controller_profile.name
598
+
599
+ @property
600
+ def pydevd_port(self) -> int:
601
+ """The pydevd port to use."""
602
+ return self.controller_profile.pydevd_port
603
+
400
604
  def get_controller_target_connections(self) -> list[SshConnection]:
401
605
  """Return SSH connection(s) for accessing the host as a target from the controller."""
402
606
  settings = SshConnectionDetail(
@@ -411,7 +615,7 @@ class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[Con
411
615
  return [SshConnection(self.args, settings)]
412
616
 
413
617
 
414
- class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[DockerConfig]):
618
+ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[DockerConfig], DebuggableProfile[DockerConfig]):
415
619
  """Host profile for a docker instance."""
416
620
 
417
621
  MARKER = 'ansible-test-marker'
@@ -425,6 +629,11 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
425
629
  command_privileged: bool
426
630
  expected_mounts: tuple[CGroupMount, ...]
427
631
 
632
+ @property
633
+ def name(self) -> str:
634
+ """The name of the host profile."""
635
+ return self.config.name
636
+
428
637
  @property
429
638
  def container_name(self) -> t.Optional[str]:
430
639
  """Return the stored container name, if any, otherwise None."""
@@ -461,7 +670,7 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
461
670
  image=self.config.image,
462
671
  name=f'ansible-test-{self.label}',
463
672
  ports=[22],
464
- publish_ports=not self.controller, # connections to the controller over SSH are not required
673
+ publish_ports=self.debugging_enabled or not self.controller, # SSH to the controller is not required unless remote debugging is enabled
465
674
  options=init_config.options,
466
675
  cleanup=False,
467
676
  cmd=self.build_init_command(init_config, init_probe),
@@ -601,7 +810,7 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
601
810
  # The host namespace must be used to permit the container to access the cgroup v1 systemd hierarchy created by Podman.
602
811
  '--cgroupns', 'host',
603
812
  # Mask the host cgroup tmpfs mount to avoid exposing the host cgroup v1 hierarchies (or cgroup v2 hybrid) to the container.
604
- # Podman will provide a cgroup v1 systemd hiearchy on top of this.
813
+ # Podman will provide a cgroup v1 systemd hierarchy on top of this.
605
814
  '--tmpfs', '/sys/fs/cgroup',
606
815
  ))
607
816
 
@@ -974,8 +1183,13 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
974
1183
  docker_logs(self.args, self.container_name)
975
1184
  raise
976
1185
 
1186
+ if self.debugging_enabled:
1187
+ self.enable_debugger_forwarding(self.get_ssh_connection_detail(HostType.origin))
1188
+
977
1189
  def deprovision(self) -> None:
978
1190
  """Deprovision the host after delegation has completed."""
1191
+ super().deprovision()
1192
+
979
1193
  container_exists = False
980
1194
 
981
1195
  if self.container_name:
@@ -1025,10 +1239,10 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
1025
1239
 
1026
1240
  raise HostConnectionError(f'Timeout waiting for {self.config.name} container {self.container_name}.', callback)
1027
1241
 
1028
- def get_controller_target_connections(self) -> list[SshConnection]:
1029
- """Return SSH connection(s) for accessing the host as a target from the controller."""
1242
+ def get_ssh_connection_detail(self, host_type: str) -> SshConnectionDetail:
1243
+ """Return SSH connection detail for the specified host type."""
1030
1244
  containers = get_container_database(self.args)
1031
- access = containers.data[HostType.control]['__test_hosts__'][self.container_name]
1245
+ access = containers.data[host_type]['__test_hosts__'][self.container_name]
1032
1246
 
1033
1247
  host = access.host_ip
1034
1248
  port = dict(access.port_map())[22]
@@ -1046,7 +1260,11 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
1046
1260
  enable_rsa_sha1='centos6' in self.config.image,
1047
1261
  )
1048
1262
 
1049
- return [SshConnection(self.args, settings)]
1263
+ return settings
1264
+
1265
+ def get_controller_target_connections(self) -> list[SshConnection]:
1266
+ """Return SSH connection(s) for accessing the host as a target from the controller."""
1267
+ return [SshConnection(self.args, self.get_ssh_connection_detail(HostType.control))]
1050
1268
 
1051
1269
  def get_origin_controller_connection(self) -> DockerConnection:
1052
1270
  """Return a connection for accessing the host as a controller from the origin."""
@@ -1116,6 +1334,11 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
1116
1334
  class NetworkInventoryProfile(HostProfile[NetworkInventoryConfig]):
1117
1335
  """Host profile for a network inventory."""
1118
1336
 
1337
+ @property
1338
+ def name(self) -> str:
1339
+ """The name of the host profile."""
1340
+ return self.config.path
1341
+
1119
1342
 
1120
1343
  class NetworkRemoteProfile(RemoteProfile[NetworkRemoteConfig]):
1121
1344
  """Host profile for a network remote instance."""
@@ -1197,6 +1420,11 @@ class NetworkRemoteProfile(RemoteProfile[NetworkRemoteConfig]):
1197
1420
  class OriginProfile(ControllerHostProfile[OriginConfig]):
1198
1421
  """Host profile for origin."""
1199
1422
 
1423
+ @property
1424
+ def name(self) -> str:
1425
+ """The name of the host profile."""
1426
+ return 'origin'
1427
+
1200
1428
  def get_origin_controller_connection(self) -> LocalConnection:
1201
1429
  """Return a connection for accessing the host as a controller from the origin."""
1202
1430
  return LocalConnection(self.args)
@@ -1206,13 +1434,18 @@ class OriginProfile(ControllerHostProfile[OriginConfig]):
1206
1434
  return os.getcwd()
1207
1435
 
1208
1436
 
1209
- class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile[PosixRemoteConfig]):
1437
+ class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile[PosixRemoteConfig], DebuggableProfile[PosixRemoteConfig]):
1210
1438
  """Host profile for a POSIX remote instance."""
1211
1439
 
1212
1440
  def wait(self) -> None:
1213
1441
  """Wait for the instance to be ready. Executed before delegation for the controller and after delegation for targets."""
1214
1442
  self.wait_until_ready()
1215
1443
 
1444
+ def setup(self) -> None:
1445
+ """Perform out-of-band setup before delegation."""
1446
+ if self.debugging_enabled:
1447
+ self.enable_debugger_forwarding(self.get_origin_controller_connection().settings)
1448
+
1216
1449
  def configure(self) -> None:
1217
1450
  """Perform in-band configuration. Executed before delegation for the controller and after delegation for targets."""
1218
1451
  # a target uses a single python version, but a controller may include additional versions for targets running on the controller
@@ -1317,6 +1550,11 @@ class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile
1317
1550
  class PosixSshProfile(SshTargetHostProfile[PosixSshConfig], PosixProfile[PosixSshConfig]):
1318
1551
  """Host profile for a POSIX SSH instance."""
1319
1552
 
1553
+ @property
1554
+ def name(self) -> str:
1555
+ """The name of the host profile."""
1556
+ return self.config.host
1557
+
1320
1558
  def get_controller_target_connections(self) -> list[SshConnection]:
1321
1559
  """Return SSH connection(s) for accessing the host as a target from the controller."""
1322
1560
  settings = SshConnectionDetail(
@@ -1334,6 +1572,11 @@ class PosixSshProfile(SshTargetHostProfile[PosixSshConfig], PosixProfile[PosixSs
1334
1572
  class WindowsInventoryProfile(SshTargetHostProfile[WindowsInventoryConfig]):
1335
1573
  """Host profile for a Windows inventory."""
1336
1574
 
1575
+ @property
1576
+ def name(self) -> str:
1577
+ """The name of the host profile."""
1578
+ return self.config.path
1579
+
1337
1580
  def get_controller_target_connections(self) -> list[SshConnection]:
1338
1581
  """Return SSH connection(s) for accessing the host as a target from the controller."""
1339
1582
  inventory = parse_inventory(self.args, self.config.path)
@@ -1436,9 +1679,9 @@ def get_config_profile_type_map() -> dict[t.Type[HostConfig], t.Type[HostProfile
1436
1679
  def create_host_profile(
1437
1680
  args: EnvironmentConfig,
1438
1681
  config: HostConfig,
1439
- controller: bool,
1682
+ controller: ControllerHostProfile | None,
1440
1683
  ) -> HostProfile:
1441
1684
  """Create and return a host profile from the given host configuration."""
1442
1685
  profile_type = get_config_profile_type_map()[type(config)]
1443
- profile = profile_type(args=args, config=config, targets=args.targets if controller else None)
1686
+ profile = profile_type(args=args, config=config, controller=controller)
1444
1687
  return profile
@@ -30,6 +30,7 @@ from .host_profiles import (
30
30
  SshTargetHostProfile,
31
31
  WindowsInventoryProfile,
32
32
  WindowsRemoteProfile,
33
+ DebuggableProfile,
33
34
  )
34
35
 
35
36
  from .ssh import (
@@ -59,6 +60,9 @@ def get_common_variables(target_profile: HostProfile, controller: bool = False)
59
60
  # To compensate for this we'll perform a `cd /` before running any commands after `sudo` succeeds.
60
61
  common_variables.update(ansible_sudo_chdir='/')
61
62
 
63
+ if isinstance(target_profile, DebuggableProfile):
64
+ common_variables.update(target_profile.get_ansiballz_inventory_variables())
65
+
62
66
  return common_variables
63
67
 
64
68
 
@@ -1,11 +1,15 @@
1
1
  """Test metadata for passing data to delegated tests."""
2
2
 
3
3
  from __future__ import annotations
4
+
5
+ import dataclasses
4
6
  import typing as t
5
7
 
6
8
  from .util import (
7
9
  display,
8
10
  generate_name,
11
+ ANSIBLE_TEST_ROOT,
12
+ ANSIBLE_LIB_ROOT,
9
13
  )
10
14
 
11
15
  from .io import (
@@ -22,13 +26,19 @@ from .diff import (
22
26
  class Metadata:
23
27
  """Metadata object for passing data to delegated tests."""
24
28
 
25
- def __init__(self) -> None:
29
+ def __init__(self, debugger_flags: DebuggerFlags) -> None:
26
30
  """Initialize metadata."""
27
31
  self.changes: dict[str, tuple[tuple[int, int], ...]] = {}
28
32
  self.cloud_config: t.Optional[dict[str, dict[str, t.Union[int, str, bool]]]] = None
29
33
  self.change_description: t.Optional[ChangeDescription] = None
30
34
  self.ci_provider: t.Optional[str] = None
31
35
  self.session_id = generate_name()
36
+ self.ansible_lib_root = ANSIBLE_LIB_ROOT
37
+ self.ansible_test_root = ANSIBLE_TEST_ROOT
38
+ self.collection_root: str | None = None
39
+ self.debugger_flags = debugger_flags
40
+ self.debugger_settings: DebuggerSettings | None = None
41
+ self.loaded = False
32
42
 
33
43
  def populate_changes(self, diff: t.Optional[list[str]]) -> None:
34
44
  """Populate the changeset using the given diff."""
@@ -55,8 +65,13 @@ class Metadata:
55
65
  changes=self.changes,
56
66
  cloud_config=self.cloud_config,
57
67
  ci_provider=self.ci_provider,
58
- change_description=self.change_description.to_dict(),
68
+ change_description=self.change_description.to_dict() if self.change_description else None,
59
69
  session_id=self.session_id,
70
+ ansible_lib_root=self.ansible_lib_root,
71
+ ansible_test_root=self.ansible_test_root,
72
+ collection_root=self.collection_root,
73
+ debugger_flags=dataclasses.asdict(self.debugger_flags),
74
+ debugger_settings=dataclasses.asdict(self.debugger_settings) if self.debugger_settings else None,
60
75
  )
61
76
 
62
77
  def to_file(self, path: str) -> None:
@@ -76,12 +91,20 @@ class Metadata:
76
91
  @staticmethod
77
92
  def from_dict(data: dict[str, t.Any]) -> Metadata:
78
93
  """Return metadata loaded from the specified dictionary."""
79
- metadata = Metadata()
94
+ metadata = Metadata(
95
+ debugger_flags=DebuggerFlags(**data['debugger_flags']),
96
+ )
97
+
80
98
  metadata.changes = data['changes']
81
99
  metadata.cloud_config = data['cloud_config']
82
100
  metadata.ci_provider = data['ci_provider']
83
- metadata.change_description = ChangeDescription.from_dict(data['change_description'])
101
+ metadata.change_description = ChangeDescription.from_dict(data['change_description']) if data['change_description'] else None
84
102
  metadata.session_id = data['session_id']
103
+ metadata.ansible_lib_root = data['ansible_lib_root']
104
+ metadata.ansible_test_root = data['ansible_test_root']
105
+ metadata.collection_root = data['collection_root']
106
+ metadata.debugger_settings = DebuggerSettings(**data['debugger_settings']) if data['debugger_settings'] else None
107
+ metadata.loaded = True
85
108
 
86
109
  return metadata
87
110
 
@@ -130,3 +153,70 @@ class ChangeDescription:
130
153
  changes.no_integration_paths = data['no_integration_paths']
131
154
 
132
155
  return changes
156
+
157
+
158
+ @dataclasses.dataclass(frozen=True, kw_only=True)
159
+ class DebuggerSettings:
160
+ """Settings for remote debugging."""
161
+
162
+ module: str | None = None
163
+ """
164
+ The Python module to import.
165
+ This should be pydevd or a derivative.
166
+ If not provided it will be auto-detected.
167
+ """
168
+
169
+ package: str | None = None
170
+ """
171
+ The Python package to install for debugging.
172
+ If `None` then the package will be auto-detected.
173
+ If an empty string, then no package will be installed.
174
+ """
175
+
176
+ settrace: dict[str, object] = dataclasses.field(default_factory=dict)
177
+ """
178
+ Options to pass to the `{module}.settrace` method.
179
+ Used for running AnsiballZ modules only.
180
+ The `host` and `port` options will be provided by ansible-test.
181
+ The `suspend` option defaults to `False`.
182
+ """
183
+
184
+ args: list[str] = dataclasses.field(default_factory=list)
185
+ """
186
+ Arguments to pass to `pydevd` on the command line.
187
+ Used for running Ansible CLI programs only.
188
+ The `--client` and `--port` options will be provided by ansible-test.
189
+ """
190
+
191
+ port: int = 5678
192
+ """
193
+ The port on the origin host which is listening for incoming connections from pydevd.
194
+ SSH port forwarding will be automatically configured for non-local hosts to connect to this port as needed.
195
+ """
196
+
197
+
198
+ @dataclasses.dataclass(frozen=True, kw_only=True)
199
+ class DebuggerFlags:
200
+ """Flags for enabling specific debugging features."""
201
+
202
+ self: bool = False
203
+ """Debug ansible-test itself."""
204
+
205
+ ansiballz: bool = False
206
+ """Debug AnsiballZ modules."""
207
+
208
+ cli: bool = False
209
+ """Debug Ansible CLI programs other than ansible-test."""
210
+
211
+ on_demand: bool = False
212
+ """Enable debugging features only when ansible-test is running under a debugger."""
213
+
214
+ @property
215
+ def enable(self) -> bool:
216
+ """Return `True` if any debugger feature other than on-demand is enabled."""
217
+ return any(getattr(self, field.name) for field in dataclasses.fields(self) if field.name != 'on_demand')
218
+
219
+ @classmethod
220
+ def all(cls, enabled: bool) -> t.Self:
221
+ """Return a `DebuggerFlags` instance with all flags enabled or disabled."""
222
+ return cls(**{field.name: enabled for field in dataclasses.fields(cls)})