ansible-core 2.19.0b6__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 (101) hide show
  1. ansible/_internal/_templating/_jinja_bits.py +16 -1
  2. ansible/_internal/_templating/_jinja_common.py +1 -1
  3. ansible/cli/__init__.py +2 -2
  4. ansible/cli/adhoc.py +6 -3
  5. ansible/cli/console.py +1 -1
  6. ansible/cli/doc.py +2 -2
  7. ansible/config/base.yml +9 -6
  8. ansible/executor/module_common.py +8 -5
  9. ansible/executor/powershell/psrp_put_file.ps1 +1 -1
  10. ansible/executor/task_executor.py +2 -2
  11. ansible/executor/task_queue_manager.py +34 -70
  12. ansible/executor/task_result.py +1 -1
  13. ansible/galaxy/api.py +2 -2
  14. ansible/galaxy/collection/concrete_artifact_manager.py +2 -2
  15. ansible/galaxy/dependency_resolution/providers.py +3 -3
  16. ansible/inventory/group.py +6 -1
  17. ansible/inventory/host.py +6 -1
  18. ansible/module_utils/_internal/_deprecator.py +12 -1
  19. ansible/module_utils/ansible_release.py +1 -1
  20. ansible/module_utils/basic.py +14 -16
  21. ansible/module_utils/common/yaml.py +1 -1
  22. ansible/module_utils/csharp/Ansible.Basic.cs +1 -1
  23. ansible/module_utils/csharp/Ansible.Privilege.cs +2 -2
  24. ansible/module_utils/facts/hardware/base.py +1 -1
  25. ansible/module_utils/facts/other/facter.py +1 -1
  26. ansible/module_utils/facts/system/distribution.py +2 -2
  27. ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 +1 -1
  28. ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 +1 -1
  29. ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 +1 -1
  30. ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 +1 -1
  31. ansible/module_utils/urls.py +1 -1
  32. ansible/modules/apt.py +9 -3
  33. ansible/modules/assemble.py +5 -3
  34. ansible/modules/expect.py +5 -5
  35. ansible/modules/hostname.py +2 -2
  36. ansible/modules/pip.py +9 -11
  37. ansible/modules/raw.py +2 -2
  38. ansible/modules/stat.py +1 -1
  39. ansible/modules/wait_for.py +10 -3
  40. ansible/parsing/mod_args.py +38 -20
  41. ansible/parsing/vault/__init__.py +3 -3
  42. ansible/playbook/base.py +0 -2
  43. ansible/playbook/helpers.py +1 -1
  44. ansible/playbook/playbook_include.py +23 -56
  45. ansible/playbook/role/__init__.py +38 -21
  46. ansible/plugins/action/__init__.py +2 -2
  47. ansible/plugins/action/assemble.py +2 -1
  48. ansible/plugins/action/assert.py +2 -2
  49. ansible/plugins/action/script.py +5 -4
  50. ansible/plugins/action/template.py +1 -1
  51. ansible/plugins/callback/__init__.py +77 -87
  52. ansible/plugins/callback/default.py +0 -3
  53. ansible/plugins/callback/junit.py +0 -6
  54. ansible/plugins/connection/ssh.py +1 -1
  55. ansible/plugins/filter/pow.yml +1 -1
  56. ansible/plugins/filter/root.yml +1 -1
  57. ansible/plugins/filter/strftime.yml +3 -3
  58. ansible/plugins/filter/to_uuid.yml +1 -1
  59. ansible/plugins/inventory/script.py +1 -1
  60. ansible/plugins/loader.py +5 -0
  61. ansible/plugins/lookup/password.py +4 -6
  62. ansible/release.py +1 -1
  63. ansible/utils/display.py +16 -26
  64. ansible/utils/path.py +1 -1
  65. ansible/utils/vars.py +4 -1
  66. ansible/vars/manager.py +6 -3
  67. ansible/vars/reserved.py +6 -4
  68. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/METADATA +1 -1
  69. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/RECORD +101 -99
  70. ansible_test/_internal/__init__.py +5 -0
  71. ansible_test/_internal/ansible_util.py +1 -1
  72. ansible_test/_internal/classification/python.py +6 -0
  73. ansible_test/_internal/cli/commands/__init__.py +0 -5
  74. ansible_test/_internal/cli/environments.py +51 -5
  75. ansible_test/_internal/commands/coverage/__init__.py +1 -1
  76. ansible_test/_internal/commands/integration/__init__.py +18 -5
  77. ansible_test/_internal/commands/integration/cloud/httptester.py +1 -1
  78. ansible_test/_internal/commands/sanity/__init__.py +3 -1
  79. ansible_test/_internal/commands/sanity/integration_aliases.py +11 -0
  80. ansible_test/_internal/commands/shell/__init__.py +43 -4
  81. ansible_test/_internal/commands/units/__init__.py +4 -1
  82. ansible_test/_internal/config.py +21 -13
  83. ansible_test/_internal/debugging.py +166 -0
  84. ansible_test/_internal/delegation.py +21 -13
  85. ansible_test/_internal/host_profiles.py +197 -6
  86. ansible_test/_internal/inventory.py +4 -0
  87. ansible_test/_internal/metadata.py +94 -4
  88. ansible_test/_internal/processes.py +80 -0
  89. ansible_test/_internal/python_requirements.py +27 -0
  90. ansible_test/_internal/target.py +8 -0
  91. ansible_test/_internal/util_common.py +13 -3
  92. ansible_test/_util/target/injector/python.py +8 -0
  93. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/WHEEL +0 -0
  94. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/entry_points.txt +0 -0
  95. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/COPYING +0 -0
  96. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/Apache-License.txt +0 -0
  97. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/BSD-3-Clause.txt +0 -0
  98. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/MIT-license.txt +0 -0
  99. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/PSF-license.txt +0 -0
  100. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/simplified_bsd.txt +0 -0
  101. {ansible_core-2.19.0b6.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 (
@@ -284,6 +292,176 @@ class HostProfile(t.Generic[THostConfig], metaclass=abc.ABCMeta):
284
292
  return f'{self.__class__.__name__}: {self.name}'
285
293
 
286
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
+
464
+
287
465
  class PosixProfile(HostProfile[TPosixConfig], metaclass=abc.ABCMeta):
288
466
  """Base class for POSIX host profiles."""
289
467
 
@@ -306,7 +484,7 @@ class PosixProfile(HostProfile[TPosixConfig], metaclass=abc.ABCMeta):
306
484
  return python
307
485
 
308
486
 
309
- class ControllerHostProfile(PosixProfile[TControllerHostConfig], metaclass=abc.ABCMeta):
487
+ class ControllerHostProfile(PosixProfile[TControllerHostConfig], DebuggableProfile[TControllerHostConfig], metaclass=abc.ABCMeta):
310
488
  """Base class for profiles usable as a controller."""
311
489
 
312
490
  @abc.abstractmethod
@@ -410,7 +588,7 @@ class RemoteProfile(SshTargetHostProfile[TRemoteConfig], metaclass=abc.ABCMeta):
410
588
  )
411
589
 
412
590
 
413
- class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[ControllerConfig]):
591
+ class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[ControllerConfig], DebuggableProfile[ControllerConfig]):
414
592
  """Host profile for the controller as a target."""
415
593
 
416
594
  @property
@@ -418,6 +596,11 @@ class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[Con
418
596
  """The name of the host profile."""
419
597
  return self.controller_profile.name
420
598
 
599
+ @property
600
+ def pydevd_port(self) -> int:
601
+ """The pydevd port to use."""
602
+ return self.controller_profile.pydevd_port
603
+
421
604
  def get_controller_target_connections(self) -> list[SshConnection]:
422
605
  """Return SSH connection(s) for accessing the host as a target from the controller."""
423
606
  settings = SshConnectionDetail(
@@ -432,7 +615,7 @@ class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[Con
432
615
  return [SshConnection(self.args, settings)]
433
616
 
434
617
 
435
- class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[DockerConfig]):
618
+ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[DockerConfig], DebuggableProfile[DockerConfig]):
436
619
  """Host profile for a docker instance."""
437
620
 
438
621
  MARKER = 'ansible-test-marker'
@@ -487,7 +670,7 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
487
670
  image=self.config.image,
488
671
  name=f'ansible-test-{self.label}',
489
672
  ports=[22],
490
- 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
491
674
  options=init_config.options,
492
675
  cleanup=False,
493
676
  cmd=self.build_init_command(init_config, init_probe),
@@ -627,7 +810,7 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
627
810
  # The host namespace must be used to permit the container to access the cgroup v1 systemd hierarchy created by Podman.
628
811
  '--cgroupns', 'host',
629
812
  # Mask the host cgroup tmpfs mount to avoid exposing the host cgroup v1 hierarchies (or cgroup v2 hybrid) to the container.
630
- # 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.
631
814
  '--tmpfs', '/sys/fs/cgroup',
632
815
  ))
633
816
 
@@ -1000,6 +1183,9 @@ class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[Do
1000
1183
  docker_logs(self.args, self.container_name)
1001
1184
  raise
1002
1185
 
1186
+ if self.debugging_enabled:
1187
+ self.enable_debugger_forwarding(self.get_ssh_connection_detail(HostType.origin))
1188
+
1003
1189
  def deprovision(self) -> None:
1004
1190
  """Deprovision the host after delegation has completed."""
1005
1191
  super().deprovision()
@@ -1248,13 +1434,18 @@ class OriginProfile(ControllerHostProfile[OriginConfig]):
1248
1434
  return os.getcwd()
1249
1435
 
1250
1436
 
1251
- class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile[PosixRemoteConfig]):
1437
+ class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile[PosixRemoteConfig], DebuggableProfile[PosixRemoteConfig]):
1252
1438
  """Host profile for a POSIX remote instance."""
1253
1439
 
1254
1440
  def wait(self) -> None:
1255
1441
  """Wait for the instance to be ready. Executed before delegation for the controller and after delegation for targets."""
1256
1442
  self.wait_until_ready()
1257
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
+
1258
1449
  def configure(self) -> None:
1259
1450
  """Perform in-band configuration. Executed before delegation for the controller and after delegation for targets."""
1260
1451
  # a target uses a single python version, but a controller may include additional versions for targets running on the controller
@@ -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)})
@@ -0,0 +1,80 @@
1
+ """Wrappers around `ps` for querying running processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import dataclasses
7
+ import os
8
+ import pathlib
9
+ import shlex
10
+
11
+ from ansible_test._internal.util import raw_command
12
+
13
+
14
+ @dataclasses.dataclass(frozen=True)
15
+ class ProcessData:
16
+ """Data about a running process."""
17
+
18
+ pid: int
19
+ ppid: int
20
+ command: str
21
+
22
+
23
+ @dataclasses.dataclass(frozen=True)
24
+ class Process:
25
+ """A process in the process tree."""
26
+
27
+ pid: int
28
+ command: str
29
+ parent: Process | None = None
30
+ children: tuple[Process, ...] = dataclasses.field(default_factory=tuple)
31
+
32
+ @property
33
+ def args(self) -> list[str]:
34
+ """The list of arguments that make up `command`."""
35
+ return shlex.split(self.command)
36
+
37
+ @property
38
+ def path(self) -> pathlib.Path:
39
+ """The path to the process."""
40
+ return pathlib.Path(self.args[0])
41
+
42
+
43
+ def get_process_data(pids: list[int] | None = None) -> list[ProcessData]:
44
+ """Return a list of running processes."""
45
+ if pids:
46
+ args = ['-p', ','.join(map(str, pids))]
47
+ else:
48
+ args = ['-A']
49
+
50
+ lines = raw_command(['ps'] + args + ['-o', 'pid,ppid,command'], capture=True)[0].splitlines()[1:]
51
+ processes = [ProcessData(pid=int(pid), ppid=int(ppid), command=command) for pid, ppid, command in (line.split(maxsplit=2) for line in lines)]
52
+
53
+ return processes
54
+
55
+
56
+ def get_process_tree() -> dict[int, Process]:
57
+ """Return the process tree."""
58
+ processes = get_process_data()
59
+ pid_to_process: dict[int, Process] = {}
60
+ pid_to_children: dict[int, list[Process]] = collections.defaultdict(list)
61
+
62
+ for data in processes:
63
+ pid_to_process[data.pid] = process = Process(pid=data.pid, command=data.command)
64
+
65
+ if data.ppid:
66
+ pid_to_children[data.ppid].append(process)
67
+
68
+ for data in processes:
69
+ pid_to_process[data.pid] = dataclasses.replace(
70
+ pid_to_process[data.pid],
71
+ parent=pid_to_process.get(data.ppid),
72
+ children=tuple(pid_to_children[data.pid]),
73
+ )
74
+
75
+ return pid_to_process
76
+
77
+
78
+ def get_current_process() -> Process:
79
+ """Return the current process along with its ancestors and descendants."""
80
+ return get_process_tree()[os.getpid()]
@@ -55,6 +55,11 @@ from .coverage_util import (
55
55
  get_coverage_version,
56
56
  )
57
57
 
58
+ if t.TYPE_CHECKING:
59
+ from .host_profiles import (
60
+ HostProfile,
61
+ )
62
+
58
63
  QUIET_PIP_SCRIPT_PATH = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'setup', 'quiet_pip.py')
59
64
  REQUIREMENTS_SCRIPT_PATH = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'setup', 'requirements.py')
60
65
 
@@ -122,6 +127,7 @@ class PipBootstrap(PipCommand):
122
127
 
123
128
  def install_requirements(
124
129
  args: EnvironmentConfig,
130
+ host_profile: HostProfile | None,
125
131
  python: PythonConfig,
126
132
  ansible: bool = False,
127
133
  command: bool = False,
@@ -133,6 +139,7 @@ def install_requirements(
133
139
  create_result_directories(args)
134
140
 
135
141
  if not requirements_allowed(args, controller):
142
+ post_install(host_profile)
136
143
  return
137
144
 
138
145
  if command and isinstance(args, (UnitsConfig, IntegrationConfig)) and args.coverage:
@@ -161,7 +168,17 @@ def install_requirements(
161
168
  sanity=None,
162
169
  )
163
170
 
171
+ from .host_profiles import DebuggableProfile
172
+
173
+ if isinstance(host_profile, DebuggableProfile) and host_profile.debugging_enabled and args.metadata.debugger_settings.package:
174
+ commands.append(PipInstall(
175
+ requirements=[],
176
+ constraints=[],
177
+ packages=[args.metadata.debugger_settings.package],
178
+ ))
179
+
164
180
  if not commands:
181
+ post_install(host_profile)
165
182
  return
166
183
 
167
184
  run_pip(args, python, commands, connection)
@@ -170,6 +187,16 @@ def install_requirements(
170
187
  if any(isinstance(command, PipInstall) and command.has_package('pyyaml') for command in commands):
171
188
  check_pyyaml(python)
172
189
 
190
+ post_install(host_profile)
191
+
192
+
193
+ def post_install(host_profile: HostProfile) -> None:
194
+ """Operations to perform after requirements are installed."""
195
+ from .host_profiles import DebuggableProfile
196
+
197
+ if isinstance(host_profile, DebuggableProfile):
198
+ host_profile.activate_debugger()
199
+
173
200
 
174
201
  def collect_bootstrap(python: PythonConfig) -> list[PipCommand]:
175
202
  """Return the details necessary to bootstrap pip into an empty virtual environment."""
@@ -582,6 +582,14 @@ class IntegrationTarget(CompletionTarget):
582
582
  else:
583
583
  static_aliases = tuple()
584
584
 
585
+ # non-group aliases which need to be extracted before group mangling occurs
586
+
587
+ self.env_set: dict[str, str] = {
588
+ match.group('key'): match.group('value') for match in (
589
+ re.match(r'env/set/(?P<key>[^/]+)/(?P<value>.*)', alias) for alias in static_aliases
590
+ ) if match
591
+ }
592
+
585
593
  # modules
586
594
 
587
595
  if self.name in modules: