Version in base suite: 2.19.0~beta6-1 Version in overlay suite: 2.19.1-0+deb13u1 Base version: ansible-core_2.19.1-0+deb13u1 Target version: ansible-core_2.19.4-0+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/a/ansible-core/ansible-core_2.19.1-0+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/a/ansible-core/ansible-core_2.19.4-0+deb13u1.dsc PKG-INFO | 2 ansible_core.egg-info/PKG-INFO | 2 ansible_core.egg-info/SOURCES.txt | 21 - changelogs/CHANGELOG-v2.19.rst | 67 +++ changelogs/changelog.yaml | 128 ++++++ debian/changelog | 15 debian/patches/integration-test-apt-sources-list.patch | 114 +++++ debian/patches/series | 1 debian/tests/ansible-test-integration.py | 10 debian/tests/testbed-setup.sh | 8 lib/ansible/_internal/_display_utils.py | 145 +++++++ lib/ansible/cli/arguments/option_helpers.py | 14 lib/ansible/cli/doc.py | 4 lib/ansible/collections/list.py | 6 lib/ansible/config/base.yml | 2 lib/ansible/executor/play_iterator.py | 2 lib/ansible/executor/powershell/async_watchdog.ps1 | 28 + lib/ansible/executor/process/worker.py | 28 - lib/ansible/executor/task_executor.py | 10 lib/ansible/executor/task_queue_manager.py | 44 ++ lib/ansible/module_utils/ansible_release.py | 2 lib/ansible/module_utils/basic.py | 2 lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 | 16 lib/ansible/module_utils/urls.py | 8 lib/ansible/modules/package_facts.py | 10 lib/ansible/parsing/mod_args.py | 3 lib/ansible/playbook/block.py | 4 lib/ansible/playbook/helpers.py | 28 - lib/ansible/playbook/play.py | 16 lib/ansible/playbook/task.py | 49 ++ lib/ansible/plugins/connection/psrp.py | 17 lib/ansible/plugins/filter/core.py | 1 lib/ansible/plugins/inventory/script.py | 3 lib/ansible/plugins/list.py | 13 lib/ansible/plugins/loader.py | 5 lib/ansible/plugins/lookup/config.py | 55 -- lib/ansible/plugins/strategy/__init__.py | 2 lib/ansible/release.py | 2 lib/ansible/utils/display.py | 184 +-------- test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/in_subdir.py | 4 test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/nested.yml | 7 test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/module_utils/__init__.py | 3 test/integration/targets/ansible-doc/runme.sh | 8 test/integration/targets/collections/test_task_resolved_plugin.sh | 16 test/integration/targets/collections/test_task_resolved_plugin/callback_plugins/display_resolved_action.py | 21 - test/integration/targets/collections/test_task_resolved_plugin/dynamic_action.yml | 10 test/integration/targets/collections/test_task_resolved_plugin/unqualified.yml | 4 test/integration/targets/connection_ssh/aliases | 1 test/integration/targets/connection_ssh/runme.sh | 2 test/integration/targets/deprecations/runme.sh | 3 test/integration/targets/filter_core/tasks/main.yml | 7 test/integration/targets/get_url/tasks/main.yml | 28 - test/integration/targets/handlers/rescue_flush_handlers.yml | 16 test/integration/targets/handlers/runme.sh | 9 test/integration/targets/handlers/tagged_play.yml | 12 test/integration/targets/include_import/roles/nested_tasks/tasks/bar.yml | 1 test/integration/targets/include_import/roles/nested_tasks/tasks/foo.yml | 1 test/integration/targets/include_import/roles/nested_tasks/tasks/main.yml | 1 test/integration/targets/include_import/runme.sh | 6 test/integration/targets/include_import/test_nested_non_existent_tasks.yml | 5 test/integration/targets/include_import_tasks_nested/tasks/main.yml | 5 test/integration/targets/include_import_tasks_nested/tests/tests_relative.yml | 6 test/integration/targets/inventory_script/bad_types | 13 test/integration/targets/inventory_script/tasks/main.yml | 198 +++++----- test/integration/targets/inventory_script/tasks/test_broken_inventory.yml | 3 test/integration/targets/inventory_script/tasks/test_valid_inventory.yml | 1 test/integration/targets/lookup_config/tasks/main.yml | 21 - test/integration/targets/signal_propagation/aliases | 3 test/integration/targets/signal_propagation/inventory | 14 test/integration/targets/signal_propagation/runme.sh | 21 + test/integration/targets/ssh_agent/tasks/tests.yml | 18 test/integration/targets/test_utils/scripts/timeout.py | 23 - test/integration/targets/win_async_wrapper/library/trailing_output.ps1 | 6 test/integration/targets/win_async_wrapper/tasks/main.yml | 15 test/integration/targets/win_exec_wrapper/tasks/main.yml | 8 test/lib/ansible_test/_internal/ci/__init__.py | 194 +++------ test/lib/ansible_test/_internal/ci/azp.py | 32 - test/lib/ansible_test/_internal/ci/local.py | 138 +++++- test/lib/ansible_test/_internal/core_ci.py | 42 +- test/lib/ansible_test/_internal/host_profiles.py | 7 test/lib/ansible_test/_internal/provisioning.py | 3 test/lib/ansible_test/_internal/util.py | 1 test/units/_internal/templating/test_jinja_bits.py | 5 test/units/_internal/templating/test_templar.py | 9 test/units/ansible_test/ci/test_azp.py | 30 - test/units/ansible_test/ci/util.py | 50 -- test/units/errors/test_utils.py | 4 test/units/parsing/yaml/test_objects.py | 4 test/units/test_utils/controller/display.py | 4 test/units/utils/test_display.py | 7 90 files changed, 1391 insertions(+), 730 deletions(-) gpgv: Signature made Tue Aug 26 22:52:03 2025 UTC gpgv: using RSA key D847C62510A9C2FF242CE02CD604A1C4823EE0F8 gpgv: Note: signatures using the SHA1 algorithm are rejected gpgv: WARNING: signing subkey D604A1C4823EE0F8 has an invalid cross-certification gpgv: Can't check signature: General error dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpdt5lr01t/ansible-core_2.19.1-0+deb13u1.dsc: no acceptable signature found gpgv: Signature made Fri Nov 7 23:13:48 2025 UTC gpgv: using RSA key D847C62510A9C2FF242CE02CD604A1C4823EE0F8 gpgv: Note: signatures using the SHA1 algorithm are rejected gpgv: WARNING: signing subkey D604A1C4823EE0F8 has an invalid cross-certification gpgv: Can't check signature: General error dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpdt5lr01t/ansible-core_2.19.4-0+deb13u1.dsc: no acceptable signature found diff -Nru ansible-core-2.19.1/PKG-INFO ansible-core-2.19.4/PKG-INFO --- ansible-core-2.19.1/PKG-INFO 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/PKG-INFO 2025-11-04 23:27:03.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: ansible-core -Version: 2.19.1 +Version: 2.19.4 Summary: Radically simple IT automation Author: Ansible Project Project-URL: Homepage, https://ansible.com/ diff -Nru ansible-core-2.19.1/ansible_core.egg-info/PKG-INFO ansible-core-2.19.4/ansible_core.egg-info/PKG-INFO --- ansible-core-2.19.1/ansible_core.egg-info/PKG-INFO 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/ansible_core.egg-info/PKG-INFO 2025-11-04 23:27:03.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: ansible-core -Version: 2.19.1 +Version: 2.19.4 Summary: Radically simple IT automation Author: Ansible Project Project-URL: Homepage, https://ansible.com/ diff -Nru ansible-core-2.19.1/ansible_core.egg-info/SOURCES.txt ansible-core-2.19.4/ansible_core.egg-info/SOURCES.txt --- ansible-core-2.19.1/ansible_core.egg-info/SOURCES.txt 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/ansible_core.egg-info/SOURCES.txt 2025-11-04 23:27:03.000000000 +0000 @@ -19,6 +19,7 @@ lib/ansible/release.py lib/ansible/_internal/__init__.py lib/ansible/_internal/_collection_proxy.py +lib/ansible/_internal/_display_utils.py lib/ansible/_internal/_event_formatting.py lib/ansible/_internal/_locking.py lib/ansible/_internal/_task.py @@ -875,12 +876,18 @@ test/integration/targets/ansible-doc/broken-docs/collections/ansible_collections/testns/testcol/plugins/modules/randommodule.py test/integration/targets/ansible-doc/broken-docs/collections/ansible_collections/testns/testcol/plugins/vars/noop_vars_plugin.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/MANIFEST.json +test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/playbooks/collections/ansible_collections/ns/col/plugins/modules/test.py +test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/__init__.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/cache/notjsonfile.py +test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/__init__.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/grouped.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/ultimatequestion.yml +test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/__init__.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/in_subdir.py +test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/nested.yml test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/inventory/statichost.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/lookup/noop.py +test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/module_utils/__init__.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/modules/fakemodule.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/modules/notrealmodule.py test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/modules/randommodule.py @@ -1794,6 +1801,7 @@ test/integration/targets/collections/roles/standalone/tasks/main.yml test/integration/targets/collections/roles/testrole/tasks/main.yml test/integration/targets/collections/test_plugins/override_formerly_core_masked_test.py +test/integration/targets/collections/test_task_resolved_plugin/dynamic_action.yml test/integration/targets/collections/test_task_resolved_plugin/fqcn.yml test/integration/targets/collections/test_task_resolved_plugin/unqualified.yml test/integration/targets/collections/test_task_resolved_plugin/unqualified_and_collections_kw.yml @@ -2294,6 +2302,7 @@ test/integration/targets/handlers/inventory.handlers test/integration/targets/handlers/nested_flush_handlers_failure_force.yml test/integration/targets/handlers/order.yml +test/integration/targets/handlers/rescue_flush_handlers.yml test/integration/targets/handlers/runme.sh test/integration/targets/handlers/tagged_play.yml test/integration/targets/handlers/test_block_as_handler-import.yml @@ -2450,6 +2459,7 @@ test/integration/targets/include_import/test_include_loop.yml test/integration/targets/include_import/test_include_loop_fqcn.yml test/integration/targets/include_import/test_loop_var_bleed.yaml +test/integration/targets/include_import/test_nested_non_existent_tasks.yml test/integration/targets/include_import/test_nested_tasks.yml test/integration/targets/include_import/test_nested_tasks_fqcn.yml test/integration/targets/include_import/test_null_include_filename.yml @@ -2551,6 +2561,9 @@ test/integration/targets/include_import/roles/nested_include_task/meta/main.yml test/integration/targets/include_import/roles/nested_include_task/tasks/main.yml test/integration/targets/include_import/roles/nested_include_task/tasks/runa.yml +test/integration/targets/include_import/roles/nested_tasks/tasks/bar.yml +test/integration/targets/include_import/roles/nested_tasks/tasks/foo.yml +test/integration/targets/include_import/roles/nested_tasks/tasks/main.yml test/integration/targets/include_import/roles/role1/tasks/canary1.yml test/integration/targets/include_import/roles/role1/tasks/canary2.yml test/integration/targets/include_import/roles/role1/tasks/canary3.yml @@ -2773,6 +2786,7 @@ test/integration/targets/inventory_ini/test_types.yml test/integration/targets/inventory_script/aliases test/integration/targets/inventory_script/bad_shebang +test/integration/targets/inventory_script/bad_types test/integration/targets/inventory_script/script_inventory_fixture.py test/integration/targets/inventory_script/tasks/main.yml test/integration/targets/inventory_script/tasks/test_broken_inventory.yml @@ -3753,6 +3767,9 @@ test/integration/targets/shell/meta/main.yml test/integration/targets/shell/tasks/command-building.yml test/integration/targets/shell/tasks/main.yml +test/integration/targets/signal_propagation/aliases +test/integration/targets/signal_propagation/inventory +test/integration/targets/signal_propagation/runme.sh test/integration/targets/slurp/aliases test/integration/targets/slurp/files/bar.bin test/integration/targets/slurp/meta/main.yml @@ -4286,6 +4303,7 @@ test/integration/targets/win_app_control/templates/manifest_v1_unsafe_expression.psd1 test/integration/targets/win_async_wrapper/aliases test/integration/targets/win_async_wrapper/library/async_test.ps1 +test/integration/targets/win_async_wrapper/library/trailing_output.ps1 test/integration/targets/win_async_wrapper/tasks/main.yml test/integration/targets/win_become/aliases test/integration/targets/win_become/tasks/main.yml @@ -4843,9 +4861,6 @@ test/units/ansible_test/test_diff.py test/units/ansible_test/_internal/__init__.py test/units/ansible_test/_internal/test_util.py -test/units/ansible_test/ci/__init__.py -test/units/ansible_test/ci/test_azp.py -test/units/ansible_test/ci/util.py test/units/ansible_test/diff/add_binary_file.diff test/units/ansible_test/diff/add_text_file.diff test/units/ansible_test/diff/add_trailing_newline.diff diff -Nru ansible-core-2.19.1/changelogs/CHANGELOG-v2.19.rst ansible-core-2.19.4/changelogs/CHANGELOG-v2.19.rst --- ansible-core-2.19.1/changelogs/CHANGELOG-v2.19.rst 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/changelogs/CHANGELOG-v2.19.rst 2025-11-04 23:27:03.000000000 +0000 @@ -4,6 +4,73 @@ .. contents:: Topics +v2.19.4 +======= + +Release Summary +--------------- + +| Release Date: 2025-11-04 +| `Porting Guide `__ + +Bugfixes +-------- + +- Fix issue where play tags prevented executing notified handlers (https://github.com/ansible/ansible/issues/85475) +- Fix issues with keywords being incorrectly validated on ``import_tasks`` (https://github.com/ansible/ansible/issues/85855, https://github.com/ansible/ansible/issues/85856) +- Fix traceback when trying to import non-existing file via nested ``import_tasks`` (https://github.com/ansible/ansible/issues/69882) +- SIGINT/SIGTERM Handling - Make SIGINT/SIGTERM handling more robust by splitting concerns between forks and the parent. +- Windows - ignore temporary file cleanup warning when using AnsibleModule to compile C# utils. This should reduce the number of warnings that can safely be ignored when running PowerShell modules - https://github.com/ansible/ansible/issues/85976 +- ansible-doc - prevent crash when scanning collections in paths that have more than one ``ansible_collections`` in it (https://github.com/ansible/ansible/issues/84909, https://github.com/ansible/ansible/pull/85361). +- callback plugins - improve consistency accessing the Task object's resolved_action attribute. +- config lookup now properly factors in variables and show_origin when checking entries from the global configuration. +- option argument deprecations now have a proper alternative help text. +- package_facts - typecast bytes to string while returning facts (https://github.com/ansible/ansible/issues/85937). +- psrp - ReadTimeout exceptions now mark host as unreachable instead of fatal (https://github.com/ansible/ansible/issues/85966) + +v2.19.3 +======= + +Release Summary +--------------- + +| Release Date: 2025-10-06 +| `Porting Guide `__ + +Minor Changes +------------- + +- fetch_file - add ca_path and cookies parameter arguments (https://github.com/ansible/ansible/issues/85172). + +Bugfixes +-------- + +- Windows async - Handle running PowerShell modules with trailing data after the module result +- ansible-doc --list/--list_files/--metadata-dump - fixed relative imports in nested filter/test plugin files (https://github.com/ansible/ansible/issues/85753). +- display - Fixed reference to undefined `_DeferredWarningContext` when issuing early warnings during startup. (https://github.com/ansible/ansible/issues/85886) +- run_command - Fixed premature selector unregistration on empty read from stdout/stderr that caused truncated output or hangs in rare situations. +- script inventory plugin will now show correct 'incorrect' type when doing implicit conversions on groups. + +v2.19.2 +======= + +Release Summary +--------------- + +| Release Date: 2025-09-08 +| `Porting Guide `__ + +Minor Changes +------------- + +- ansible-test - Implement new authentication methods for accessing the Ansible Core CI service. + +Bugfixes +-------- + +- The ``ansible_failed_task`` variable is now correctly exposed in a rescue section, even when a failing handler is triggered by the ``flush_handlers`` task in the corresponding ``block`` (https://github.com/ansible/ansible/issues/85682) +- ``ternary`` filter - evaluate values lazily (https://github.com/ansible/ansible/issues/85743) + v2.19.1 ======= diff -Nru ansible-core-2.19.1/changelogs/changelog.yaml ansible-core-2.19.4/changelogs/changelog.yaml --- ansible-core-2.19.1/changelogs/changelog.yaml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/changelogs/changelog.yaml 2025-11-04 23:27:03.000000000 +0000 @@ -1280,3 +1280,131 @@ - templating-filter-generators.yml - tqm.yml release_date: '2025-08-18' + 2.19.2: + changes: + release_summary: '| Release Date: 2025-09-08 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.2_summary.yaml + release_date: '2025-09-08' + 2.19.2rc1: + changes: + bugfixes: + - The ``ansible_failed_task`` variable is now correctly exposed in a rescue + section, even when a failing handler is triggered by the ``flush_handlers`` + task in the corresponding ``block`` (https://github.com/ansible/ansible/issues/85682) + - '``ternary`` filter - evaluate values lazily (https://github.com/ansible/ansible/issues/85743)' + minor_changes: + - ansible-test - Implement new authentication methods for accessing the Ansible + Core CI service. + release_summary: '| Release Date: 2025-09-02 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.2rc1_summary.yaml + - 85682-rescue-flush_handlers.yml + - 85743-lazy-ternary.yml + - ansible-test-auth-update.yml + release_date: '2025-09-02' + 2.19.3: + changes: + release_summary: '| Release Date: 2025-10-06 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.3_summary.yaml + release_date: '2025-10-06' + 2.19.3rc1: + changes: + bugfixes: + - Windows async - Handle running PowerShell modules with trailing data after + the module result + - ansible-doc --list/--list_files/--metadata-dump - fixed relative imports in + nested filter/test plugin files (https://github.com/ansible/ansible/issues/85753). + - display - Fixed reference to undefined `_DeferredWarningContext` when issuing + early warnings during startup. (https://github.com/ansible/ansible/issues/85886) + - run_command - Fixed premature selector unregistration on empty read from stdout/stderr + that caused truncated output or hangs in rare situations. + - script inventory plugin will now show correct 'incorrect' type when doing + implicit conversions on groups. + minor_changes: + - fetch_file - add ca_path and cookies parameter arguments (https://github.com/ansible/ansible/issues/85172). + release_summary: '| Release Date: 2025-09-29 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.3rc1_summary.yaml + - display_internals.yml + - fetch_file.yml + - fix-listing-nested-filter-and-test-plugins.yml + - fix_script_error.yml + - run_command_output_selector.yml + - win_async-junk-output.yml + release_date: '2025-09-29' + 2.19.4: + changes: + release_summary: '| Release Date: 2025-11-04 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.4_summary.yaml + release_date: '2025-11-04' + 2.19.4rc1: + changes: + bugfixes: + - Fix issue where play tags prevented executing notified handlers (https://github.com/ansible/ansible/issues/85475) + - Fix issues with keywords being incorrectly validated on ``import_tasks`` (https://github.com/ansible/ansible/issues/85855, + https://github.com/ansible/ansible/issues/85856) + - Fix traceback when trying to import non-existing file via nested ``import_tasks`` + (https://github.com/ansible/ansible/issues/69882) + - SIGINT/SIGTERM Handling - Make SIGINT/SIGTERM handling more robust by splitting + concerns between forks and the parent. + - Windows - ignore temporary file cleanup warning when using AnsibleModule to + compile C# utils. This should reduce the number of warnings that can safely + be ignored when running PowerShell modules - https://github.com/ansible/ansible/issues/85976 + - ansible-doc - prevent crash when scanning collections in paths that have more + than one ``ansible_collections`` in it (https://github.com/ansible/ansible/issues/84909, + https://github.com/ansible/ansible/pull/85361). + - callback plugins - improve consistency accessing the Task object's resolved_action + attribute. + - config lookup now properly factors in variables and show_origin when checking + entries from the global configuration. + - option argument deprecations now have a proper alternative help text. + - package_facts - typecast bytes to string while returning facts (https://github.com/ansible/ansible/issues/85937). + - psrp - ReadTimeout exceptions now mark host as unreachable instead of fatal + (https://github.com/ansible/ansible/issues/85966) + release_summary: '| Release Date: 2025-10-29 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.4rc1_summary.yaml + - 85361-collection-name-from-path-none.yml + - 85475-fix-flush_handlers-play-tags.yml + - 85524-resolve-task-resolved_action-early.yml + - 85966-psrp-readtimeout.yml + - add-type-warning.yml + - config_lookup_fix.yml + - fix-signal-propagation.yml + - import_tasks-fixes.yml + - option_deprecation_help.yml + - package_facts.yml + release_date: '2025-10-29' diff -Nru ansible-core-2.19.1/debian/changelog ansible-core-2.19.4/debian/changelog --- ansible-core-2.19.1/debian/changelog 2025-08-26 20:44:31.000000000 +0000 +++ ansible-core-2.19.4/debian/changelog 2025-11-07 22:26:04.000000000 +0000 @@ -1,3 +1,18 @@ +ansible-core (2.19.4-0+deb13u1) trixie; urgency=medium + + [ Lee Garrett ] + * New upstream bugfix release 2.19.4 + - Fix regression from 2.18 regarding handlers and play tags (Closes: + #1114932) + * d/t/ansible-test-integration.py: Match conditional with log verbosity + * autopkgtest: Always emit output when testbed-setup.sh is run + + [ Colin Watson ] + * Move apt sources lists aside more comprehensively in tests + * testbed-setup: Only remove autopkgtest's global pinning + + -- Lee Garrett Fri, 07 Nov 2025 23:26:04 +0100 + ansible-core (2.19.1-0+deb13u1) trixie; urgency=medium * New upstream bugfix release 2.19.1 diff -Nru ansible-core-2.19.1/debian/patches/integration-test-apt-sources-list.patch ansible-core-2.19.4/debian/patches/integration-test-apt-sources-list.patch --- ansible-core-2.19.1/debian/patches/integration-test-apt-sources-list.patch 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/debian/patches/integration-test-apt-sources-list.patch 2025-11-05 18:29:02.000000000 +0000 @@ -0,0 +1,114 @@ +From: Colin Watson +Date: Tue, 21 Oct 2025 19:44:20 +0100 +Subject: Move apt sources lists aside more comprehensively + +Forwarded: https://github.com/ansible/ansible/pull/86050 +Last-Update: 2025-10-21 +--- + test/integration/targets/apt/tasks/downgrade.yml | 11 +++++++++-- + test/integration/targets/apt/tasks/repo.yml | 22 ++++++++++++++++++---- + test/integration/targets/apt/tasks/upgrade.yml | 11 +++++++++-- + 3 files changed, 36 insertions(+), 8 deletions(-) + +diff --git a/test/integration/targets/apt/tasks/downgrade.yml b/test/integration/targets/apt/tasks/downgrade.yml +index e80b099..0d11160 100644 +--- a/test/integration/targets/apt/tasks/downgrade.yml ++++ b/test/integration/targets/apt/tasks/downgrade.yml +@@ -1,6 +1,10 @@ + - block: + - name: Disable ubuntu repos so system packages are not upgraded and do not change testing env +- command: mv /etc/apt/sources.list /etc/apt/sources.list.backup ++ shell: | ++ find /etc/apt/sources.list* \ ++ \( -name \*.list -or -name \*.sources \) \ ++ -and -not -name file_tmp_repo.list \ ++ | xargs -I{} mv {} {}.backup + + - name: install latest foo + apt: +@@ -74,4 +78,7 @@ + autoclean: yes + + - name: Restore ubuntu repos +- command: mv /etc/apt/sources.list.backup /etc/apt/sources.list ++ shell: | ++ find /etc/apt/sources.list* -name \*.backup \ ++ | sed 's/\.backup$//' \ ++ | xargs -I{} mv {}.backup {} +diff --git a/test/integration/targets/apt/tasks/repo.yml b/test/integration/targets/apt/tasks/repo.yml +index 5f60503..448c238 100644 +--- a/test/integration/targets/apt/tasks/repo.yml ++++ b/test/integration/targets/apt/tasks/repo.yml +@@ -206,7 +206,11 @@ + # https://github.com/ansible/ansible/issues/35900 + - block: + - name: Disable ubuntu repos so system packages are not upgraded and do not change testing env +- command: mv /etc/apt/sources.list /etc/apt/sources.list.backup ++ shell: | ++ find /etc/apt/sources.list* \ ++ \( -name \*.list -or -name \*.sources \) \ ++ -and -not -name file_tmp_repo.list \ ++ | xargs -I{} mv {} {}.backup + + - name: Install foobar, installs foo as a dependency + apt: +@@ -273,13 +277,20 @@ + autoclean: yes + + - name: Restore ubuntu repos +- command: mv /etc/apt/sources.list.backup /etc/apt/sources.list ++ shell: | ++ find /etc/apt/sources.list* -name \*.backup \ ++ | sed 's/\.backup$//' \ ++ | xargs -I{} mv {}.backup {} + + + # https://github.com/ansible/ansible/issues/26298 + - block: + - name: Disable ubuntu repos so system packages are not upgraded and do not change testing env +- command: mv /etc/apt/sources.list /etc/apt/sources.list.backup ++ shell: | ++ find /etc/apt/sources.list* \ ++ \( -name \*.list -or -name \*.sources \) \ ++ -and -not -name file_tmp_repo.list \ ++ | xargs -I{} mv {} {}.backup + + - name: Install foobar, installs foo as a dependency + apt: +@@ -360,7 +371,10 @@ + autoclean: yes + + - name: Restore ubuntu repos +- command: mv /etc/apt/sources.list.backup /etc/apt/sources.list ++ shell: | ++ find /etc/apt/sources.list* -name \*.backup \ ++ | sed 's/\.backup$//' \ ++ | xargs -I{} mv {}.backup {} + + - name: Downgrades + import_tasks: "downgrade.yml" +diff --git a/test/integration/targets/apt/tasks/upgrade.yml b/test/integration/targets/apt/tasks/upgrade.yml +index 719d4e6..037a400 100644 +--- a/test/integration/targets/apt/tasks/upgrade.yml ++++ b/test/integration/targets/apt/tasks/upgrade.yml +@@ -1,6 +1,10 @@ + - block: + - name: Disable ubuntu repos so system packages are not upgraded and do not change testing env +- command: mv /etc/apt/sources.list /etc/apt/sources.list.backup ++ shell: | ++ find /etc/apt/sources.list* \ ++ \( -name \*.list -or -name \*.sources \) \ ++ -and -not -name file_tmp_repo.list \ ++ | xargs -I{} mv {} {}.backup + + - name: install foo-1.0.0 + apt: +@@ -61,4 +65,7 @@ + autoclean: yes + + - name: Restore ubuntu repos +- command: mv /etc/apt/sources.list.backup /etc/apt/sources.list ++ shell: | ++ find /etc/apt/sources.list* -name \*.backup \ ++ | sed 's/\.backup$//' \ ++ | xargs -I{} mv {}.backup {} diff -Nru ansible-core-2.19.1/debian/patches/series ansible-core-2.19.4/debian/patches/series --- ansible-core-2.19.1/debian/patches/series 2025-08-26 10:45:56.000000000 +0000 +++ ansible-core-2.19.4/debian/patches/series 2025-11-05 18:29:50.000000000 +0000 @@ -1,3 +1,4 @@ use-py3.patch fix-integration-tests.patch fix-integration-test-apt.patch +integration-test-apt-sources-list.patch diff -Nru ansible-core-2.19.1/debian/tests/ansible-test-integration.py ansible-core-2.19.4/debian/tests/ansible-test-integration.py --- ansible-core-2.19.1/debian/tests/ansible-test-integration.py 2025-08-26 20:34:56.000000000 +0000 +++ ansible-core-2.19.4/debian/tests/ansible-test-integration.py 2025-11-05 18:28:39.000000000 +0000 @@ -37,7 +37,7 @@ return proc def locale_debug(name): - if args.verbose >= 3: + if args.verbose >= 2: log(2, name) log(2, 'output of /usr/bin/locale:') subprocess.run(['/usr/bin/locale']) @@ -142,10 +142,10 @@ if args.setup is True and args.dry_run == False: proc = runprog('testbed-setup.sh', ['sudo', './debian/tests/testbed-setup.sh']) - log(2,"#### STDOUT ####") - log(2, proc.stdout) - log(2, "#### STDERR ####") - log(2, proc.stderr) + log(0,"#### STDOUT ####") + log(0, proc.stdout) + log(0, "#### STDERR ####") + log(0, proc.stderr) locale_debug('locale after running testbed-setup.sh:') # integration tests requiring a running ssh server diff -Nru ansible-core-2.19.1/debian/tests/testbed-setup.sh ansible-core-2.19.4/debian/tests/testbed-setup.sh --- ansible-core-2.19.1/debian/tests/testbed-setup.sh 2025-08-26 10:45:56.000000000 +0000 +++ ansible-core-2.19.4/debian/tests/testbed-setup.sh 2025-11-05 18:36:59.000000000 +0000 @@ -44,9 +44,11 @@ # allow pip to install system packages for the tests rm -f /usr/lib/python3*/EXTERNALLY-MANAGED -# remove pinning, as that breaks the apt and deb822_repository -# integration test, see https://github.com/ansible/ansible/issues/85147 -rm -f /etc/apt/preferences.d/* +# Remove autopkgtest's global pinning, as that breaks the apt and +# deb822_repository integration tests; see +# https://github.com/ansible/ansible/issues/85147. Don't touch more +# specific pins such as those created by "autopkgtest --pin-packages". +grep -lr 'Pin: origin ""' /etc/apt/preferences.d | xargs -r rm -f # Workaround for integration test "apt", because of policy in base-files # See diff -Nru ansible-core-2.19.1/lib/ansible/_internal/_display_utils.py ansible-core-2.19.4/lib/ansible/_internal/_display_utils.py --- ansible-core-2.19.1/lib/ansible/_internal/_display_utils.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/_internal/_display_utils.py 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,145 @@ +from __future__ import annotations + +import dataclasses + +from ansible.module_utils._internal import _ambient_context, _messages +from . import _event_formatting + + +class DeferredWarningContext(_ambient_context.AmbientContextBase): + """ + Calls to `Display.warning()` and `Display.deprecated()` within this context will cause the resulting warnings to be captured and not displayed. + The intended use is for task-initiated warnings to be recorded with the task result, which makes them visible to registered results, callbacks, etc. + The active display callback is responsible for communicating any warnings to the user. + """ + + # DTFIX-FUTURE: once we start implementing nested scoped contexts for our own bookkeeping, this should be an interface facade that forwards to the nearest + # context that actually implements the warnings collection capability + + def __init__(self, *, variables: dict[str, object]) -> None: + self._variables = variables # DTFIX-FUTURE: move this to an AmbientContext-derived TaskContext (once it exists) + self._deprecation_warnings: list[_messages.DeprecationSummary] = [] + self._warnings: list[_messages.WarningSummary] = [] + self._seen: set[_messages.WarningSummary] = set() + + def capture(self, warning: _messages.WarningSummary) -> None: + """Add the warning/deprecation to the context if it has not already been seen by this context.""" + if warning in self._seen: + return + + self._seen.add(warning) + + if isinstance(warning, _messages.DeprecationSummary): + self._deprecation_warnings.append(warning) + else: + self._warnings.append(warning) + + def get_warnings(self) -> list[_messages.WarningSummary]: + """Return a list of the captured non-deprecation warnings.""" + # DTFIX-FUTURE: return a read-only list proxy instead + return self._warnings + + def get_deprecation_warnings(self) -> list[_messages.DeprecationSummary]: + """Return a list of the captured deprecation warnings.""" + # DTFIX-FUTURE: return a read-only list proxy instead + return self._deprecation_warnings + + +def format_message(summary: _messages.SummaryBase, include_traceback: bool) -> str: + if isinstance(summary, _messages.DeprecationSummary): + deprecation_message = get_deprecation_message_with_plugin_info( + msg=summary.event.msg, + version=summary.version, + date=summary.date, + deprecator=summary.deprecator, + ) + + event = dataclasses.replace(summary.event, msg=deprecation_message) + else: + event = summary.event + + return _event_formatting.format_event(event, include_traceback) + + +def get_deprecation_message_with_plugin_info( + *, + msg: str, + version: str | None, + removed: bool = False, + date: str | None, + deprecator: _messages.PluginInfo | None, +) -> str: + """Internal use only. Return a deprecation message and help text for display.""" + # DTFIX-FUTURE: the logic for omitting date/version doesn't apply to the payload, so it shows up in vars in some cases when it should not + + if removed: + removal_fragment = 'This feature was removed' + else: + removal_fragment = 'This feature will be removed' + + if not deprecator or not deprecator.type: + # indeterminate has no resolved_name or type + # collections have a resolved_name but no type + collection = deprecator.resolved_name if deprecator else None + plugin_fragment = '' + elif deprecator.resolved_name == 'ansible.builtin': + # core deprecations from base classes (the API) have no plugin name, only 'ansible.builtin' + plugin_type_name = str(deprecator.type) if deprecator.type is _messages.PluginType.MODULE else f'{deprecator.type} plugin' + + collection = deprecator.resolved_name + plugin_fragment = f'the {plugin_type_name} API' + else: + parts = deprecator.resolved_name.split('.') + plugin_name = parts[-1] + plugin_type_name = str(deprecator.type) if deprecator.type is _messages.PluginType.MODULE else f'{deprecator.type} plugin' + + collection = '.'.join(parts[:2]) if len(parts) > 2 else None + plugin_fragment = f'{plugin_type_name} {plugin_name!r}' + + if collection and plugin_fragment: + plugin_fragment += ' in' + + if collection == 'ansible.builtin': + collection_fragment = 'ansible-core' + elif collection: + collection_fragment = f'collection {collection!r}' + else: + collection_fragment = '' + + if not collection: + when_fragment = 'in the future' if not removed else '' + elif date: + when_fragment = f'in a release after {date}' + elif version: + when_fragment = f'version {version}' + else: + when_fragment = 'in a future release' if not removed else '' + + if plugin_fragment or collection_fragment: + from_fragment = 'from' + else: + from_fragment = '' + + deprecation_msg = ' '.join(f for f in [removal_fragment, from_fragment, plugin_fragment, collection_fragment, when_fragment] if f) + '.' + + return join_sentences(msg, deprecation_msg) + + +def join_sentences(first: str | None, second: str | None) -> str: + """Join two sentences together.""" + first = (first or '').strip() + second = (second or '').strip() + + if first and first[-1] not in ('!', '?', '.'): + first += '.' + + if second and second[-1] not in ('!', '?', '.'): + second += '.' + + if first and not second: + return first + + if not first and second: + return second + + return ' '.join((first, second)) diff -Nru ansible-core-2.19.1/lib/ansible/cli/arguments/option_helpers.py ansible-core-2.19.4/lib/ansible/cli/arguments/option_helpers.py --- ansible-core-2.19.1/lib/ansible/cli/arguments/option_helpers.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/cli/arguments/option_helpers.py 2025-11-04 23:27:03.000000000 +0000 @@ -44,6 +44,9 @@ option: str | None = None """The specific option string that is deprecated; None applies to all options for this argument.""" + alternatives: str | None = None + """The options to use instead.""" + def is_deprecated(self, option: str) -> bool: """Return True if the given option is deprecated, otherwise False.""" return self.option is None or option == self.option @@ -58,6 +61,7 @@ Display().deprecated( # pylint: disable=ansible-invalid-deprecated-version msg=f'The {option!r} argument is deprecated.', version=self.version, + help_text=f'Use {self.alternatives} instead.' if self.alternatives else None ) @@ -419,7 +423,7 @@ """Add options for commands that utilize inventory""" parser.add_argument('-i', '--inventory', '--inventory-file', dest='inventory', action="append", help="specify inventory host path or comma separated host list", - deprecated=DeprecatedArgument(version='2.23', option='--inventory-file')) + deprecated=DeprecatedArgument(version='2.23', option='--inventory-file', alternatives="-i or --inventory")) parser.add_argument('--list-hosts', dest='listhosts', action='store_true', help='outputs a list of matching hosts; does not execute anything else') parser.add_argument('-l', '--limit', default=C.DEFAULT_SUBSET, dest='subset', @@ -444,10 +448,10 @@ def add_output_options(parser): """Add options for commands which can change their output""" - parser.add_argument('-o', '--one-line', dest='one_line', action='store_true', - help='condense output', deprecated=DeprecatedArgument(version='2.23')) - parser.add_argument('-t', '--tree', dest='tree', default=None, - help='log output to this directory', deprecated=DeprecatedArgument(version='2.23')) + parser.add_argument('-o', '--one-line', dest='one_line', action='store_true', help='condense output', + deprecated=DeprecatedArgument(version='2.23', alternatives='callback configuration to enable the oneline callback')) + parser.add_argument('-t', '--tree', dest='tree', default=None, help='log output to this directory', + deprecated=DeprecatedArgument(version='2.23', alternatives='callback configuration to enable the tree callback')) def add_runas_options(parser): diff -Nru ansible-core-2.19.1/lib/ansible/cli/doc.py ansible-core-2.19.4/lib/ansible/cli/doc.py --- ansible-core-2.19.1/lib/ansible/cli/doc.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/cli/doc.py 2025-11-04 23:27:03.000000000 +0000 @@ -237,7 +237,9 @@ b_colldirs = list_collection_dirs(coll_filter=collection_filter) for b_path in b_colldirs: path = to_text(b_path, errors='surrogate_or_strict') - collname = _get_collection_name_from_path(b_path) + if not (collname := _get_collection_name_from_path(b_path)): + display.debug(f'Skipping invalid path {b_path!r}') + continue roles_dir = os.path.join(path, 'roles') if os.path.exists(roles_dir): diff -Nru ansible-core-2.19.1/lib/ansible/collections/list.py ansible-core-2.19.4/lib/ansible/collections/list.py --- ansible-core-2.19.1/lib/ansible/collections/list.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/collections/list.py 2025-11-04 23:27:03.000000000 +0000 @@ -17,8 +17,10 @@ collections = {} for candidate in list_collection_dirs(search_paths=search_paths, coll_filter=coll_filter, artifacts_manager=artifacts_manager, dedupe=dedupe): - collection = _get_collection_name_from_path(candidate) - collections[collection] = candidate + if collection := _get_collection_name_from_path(candidate): + collections[collection] = candidate + else: + display.debug(f'Skipping invalid collection in path: {candidate!r}') return collections diff -Nru ansible-core-2.19.1/lib/ansible/config/base.yml ansible-core-2.19.4/lib/ansible/config/base.yml --- ansible-core-2.19.1/lib/ansible/config/base.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/config/base.yml 2025-11-04 23:27:03.000000000 +0000 @@ -2257,6 +2257,8 @@ why: for testing version: '3.30' alternatives: nothing + vars: + - name: _z_test_entry _Z_TEST_ENTRY_2: version_added: '2.18' name: testentry diff -Nru ansible-core-2.19.1/lib/ansible/executor/play_iterator.py ansible-core-2.19.4/lib/ansible/executor/play_iterator.py --- ansible-core-2.19.1/lib/ansible/executor/play_iterator.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/executor/play_iterator.py 2025-11-04 23:27:03.000000000 +0000 @@ -574,7 +574,7 @@ Given the current HostState state, determines if the current block, or any child blocks, are in rescue mode. """ - if state.run_state == IteratingStates.TASKS and state.get_current_block().rescue: + if state.run_state in (IteratingStates.TASKS, IteratingStates.HANDLERS) and state.get_current_block().rescue: return True if state.tasks_child_state is not None: return self.is_any_block_rescuing(state.tasks_child_state) diff -Nru ansible-core-2.19.1/lib/ansible/executor/powershell/async_watchdog.ps1 ansible-core-2.19.4/lib/ansible/executor/powershell/async_watchdog.ps1 --- ansible-core-2.19.1/lib/ansible/executor/powershell/async_watchdog.ps1 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/executor/powershell/async_watchdog.ps1 2025-11-04 23:27:03.000000000 +0000 @@ -67,14 +67,34 @@ $result.finished = $true if ($jobAsyncResult.IsCompleted) { - $jobOutput = $ps.EndInvoke($jobAsyncResult) + $jobOutput = @($ps.EndInvoke($jobAsyncResult) | Out-String) -join "`n" $jobError = $ps.Streams.Error # write success/output/error to result object - # TODO: cleanse leading/trailing junk - $moduleResult = $jobOutput | ConvertFrom-Json | Convert-JsonObject + $moduleResultJson = $jobOutput + $startJsonChar = $moduleResultJson.IndexOf([char]'{') + if ($startJsonChar -eq -1) { + throw "No start of json char found in module result" + } + $moduleResultJson = $moduleResultJson.Substring($startJsonChar) + + $endJsonChar = $moduleResultJson.LastIndexOf([char]'}') + if ($endJsonChar -eq -1) { + throw "No end of json char found in module result" + } + + $trailingJunk = $moduleResultJson.Substring($endJsonChar + 1).Trim() + $moduleResultJson = $moduleResultJson.Substring(0, $endJsonChar + 1) + $moduleResult = $moduleResultJson | ConvertFrom-Json | Convert-JsonObject # TODO: check for conflicting keys $result = $result + $moduleResult + + if ($trailingJunk) { + if (-not $result.warnings) { + $result.warnings = @() + } + $result.warnings += "Module invocation had junk after the JSON data: $trailingJunk" + } } else { # We can't call Stop() as pwsh won't respond if it is busy calling a .NET @@ -103,7 +123,7 @@ $result.failed = $true $result.msg = "failure during async watchdog: $_" # return output back, if available, to Ansible to help with debugging errors - $result.stdout = $jobOutput | Out-String + $result.stdout = $jobOutput $result.stderr = $jobError | Out-String } finally { diff -Nru ansible-core-2.19.1/lib/ansible/executor/process/worker.py ansible-core-2.19.4/lib/ansible/executor/process/worker.py --- ansible-core-2.19.1/lib/ansible/executor/process/worker.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/executor/process/worker.py 2025-11-04 23:27:03.000000000 +0000 @@ -17,6 +17,7 @@ from __future__ import annotations +import errno import io import os import signal @@ -103,11 +104,19 @@ self._cliargs = cliargs def _term(self, signum, frame) -> None: - """ - terminate the process group created by calling setsid when - a terminate signal is received by the fork - """ - os.killpg(self.pid, signum) + """In child termination when notified by the parent""" + signal.signal(signum, signal.SIG_DFL) + + try: + os.killpg(self.pid, signum) + os.kill(self.pid, signum) + except OSError as e: + if e.errno != errno.ESRCH: + signame = signal.strsignal(signum) + display.error(f'Unable to send {signame} to child[{self.pid}]: {e}') + + # fallthrough, if we are still here, just die + os._exit(1) def start(self) -> None: """ @@ -121,11 +130,6 @@ # FUTURE: this lock can be removed once a more generalized pre-fork thread pause is in place with display._lock: super(WorkerProcess, self).start() - # Since setsid is called later, if the worker is termed - # it won't term the new process group - # register a handler to propagate the signal - signal.signal(signal.SIGTERM, self._term) - signal.signal(signal.SIGINT, self._term) def _hard_exit(self, e: str) -> t.NoReturn: """ @@ -170,7 +174,6 @@ # to give better errors, and to prevent fd 0 reuse sys.stdin.close() except Exception as e: - display.debug(f'Could not detach from stdio: {traceback.format_exc()}') display.error(f'Could not detach from stdio: {e}') os._exit(1) @@ -187,6 +190,9 @@ # Set the queue on Display so calls to Display.display are proxied over the queue display.set_queue(self._final_q) self._detach() + # propagate signals + signal.signal(signal.SIGINT, self._term) + signal.signal(signal.SIGTERM, self._term) try: with _task.TaskContext(self._task): return self._run() diff -Nru ansible-core-2.19.1/lib/ansible/executor/task_executor.py ansible-core-2.19.4/lib/ansible/executor/task_executor.py --- ansible-core-2.19.1/lib/ansible/executor/task_executor.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/executor/task_executor.py 2025-11-04 23:27:03.000000000 +0000 @@ -19,6 +19,8 @@ AnsibleError, AnsibleParserError, AnsibleUndefinedVariable, AnsibleTaskError, AnsibleValueOmittedError, ) + +from ansible._internal import _display_utils from ansible.executor.task_result import _RawTaskResult from ansible._internal._datatag import _utils from ansible.module_utils._internal import _messages @@ -35,7 +37,7 @@ from ansible._internal._templating._engine import TemplateEngine from ansible.template import Templar from ansible.utils.collection_loader import AnsibleCollectionConfig -from ansible.utils.display import Display, _DeferredWarningContext +from ansible.utils.display import Display from ansible.utils.vars import combine_vars from ansible.vars.clean import namespace_facts, clean_facts from ansible.vars.manager import _deprecate_top_level_fact @@ -416,7 +418,7 @@ def _execute(self, templar: TemplateEngine, variables: dict[str, t.Any]) -> dict[str, t.Any]: result: dict[str, t.Any] - with _DeferredWarningContext(variables=variables) as warning_ctx: + with _display_utils.DeferredWarningContext(variables=variables) as warning_ctx: try: # DTFIX-FUTURE: improve error handling to prioritize the earliest exception, turning the remaining ones into warnings result = self._execute_internal(templar, variables) @@ -431,7 +433,7 @@ self._task.update_result_no_log(templar, result) - # The warnings/deprecations in the result have already been captured in the _DeferredWarningContext by _apply_task_result_compat. + # The warnings/deprecations in the result have already been captured in the DeferredWarningContext by _apply_task_result_compat. # The captured warnings/deprecations are a superset of the ones from the result, and may have been converted from a dict to a dataclass. # These are then used to supersede the entries in the result. @@ -788,7 +790,7 @@ return result @staticmethod - def _apply_task_result_compat(result: dict[str, t.Any], warning_ctx: _DeferredWarningContext) -> None: + def _apply_task_result_compat(result: dict[str, t.Any], warning_ctx: _display_utils.DeferredWarningContext) -> None: """Apply backward-compatibility mutations to the supplied task result.""" if warnings := result.get('warnings'): if isinstance(warnings, list): diff -Nru ansible-core-2.19.1/lib/ansible/executor/task_queue_manager.py ansible-core-2.19.4/lib/ansible/executor/task_queue_manager.py --- ansible-core-2.19.1/lib/ansible/executor/task_queue_manager.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/executor/task_queue_manager.py 2025-11-04 23:27:03.000000000 +0000 @@ -18,8 +18,10 @@ from __future__ import annotations import dataclasses +import errno import os import sys +import signal import tempfile import threading import time @@ -187,8 +189,48 @@ # plugins for inter-process locking. self._connection_lockfile = tempfile.TemporaryFile() + self._workers: list[WorkerProcess | None] = [] + + # signal handlers to propagate signals to workers + signal.signal(signal.SIGTERM, self._signal_handler) + signal.signal(signal.SIGINT, self._signal_handler) + def _initialize_processes(self, num: int) -> None: - self._workers: list[WorkerProcess | None] = [None] * num + # mutable update to ensure the reference stays the same + self._workers[:] = [None] * num + + def _signal_handler(self, signum, frame) -> None: + """ + terminate all running process groups created as a result of calling + setsid from within a WorkerProcess. + + Since the children become process leaders, signals will not + automatically propagate to them. + """ + signal.signal(signum, signal.SIG_DFL) + + for worker in self._workers: + if worker is None or not worker.is_alive(): + continue + if worker.pid: + try: + # notify workers + os.kill(worker.pid, signum) + except OSError as e: + if e.errno != errno.ESRCH: + signame = signal.strsignal(signum) + display.error(f'Unable to send {signame} to child[{worker.pid}]: {e}') + + if signum == signal.SIGINT: + # Defer to CLI handling + raise KeyboardInterrupt() + + pid = os.getpid() + try: + os.kill(pid, signum) + except OSError as e: + signame = signal.strsignal(signum) + display.error(f'Unable to send {signame} to {pid}: {e}') def load_callbacks(self): """ diff -Nru ansible-core-2.19.1/lib/ansible/module_utils/ansible_release.py ansible-core-2.19.4/lib/ansible/module_utils/ansible_release.py --- ansible-core-2.19.1/lib/ansible/module_utils/ansible_release.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/module_utils/ansible_release.py 2025-11-04 23:27:03.000000000 +0000 @@ -17,6 +17,6 @@ from __future__ import annotations -__version__ = '2.19.1' +__version__ = '2.19.4' __author__ = 'Ansible, Inc.' __codename__ = "What Is and What Should Never Be" diff -Nru ansible-core-2.19.1/lib/ansible/module_utils/basic.py ansible-core-2.19.4/lib/ansible/module_utils/basic.py --- ansible-core-2.19.1/lib/ansible/module_utils/basic.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/module_utils/basic.py 2025-11-04 23:27:03.000000000 +0000 @@ -2090,7 +2090,7 @@ stdout_changed = False for key, event in events: b_chunk = key.fileobj.read(32768) - if not b_chunk: + if not b_chunk and b_chunk is not None: selector.unregister(key.fileobj) elif key.fileobj == cmd.stdout: stdout += b_chunk diff -Nru ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 ansible-core-2.19.4/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 --- ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 2025-11-04 23:27:03.000000000 +0000 @@ -278,10 +278,16 @@ if ($PSCmdlet.ParameterSetName -eq "Module") { $temp_path = $AnsibleModule.Tmpdir $include_debug = $AnsibleModule.Verbosity -ge 3 + + # AnsibleModule will handle the cleanup after module execution + # which should be enough time for AVs or other processes to release + # any locks on the temp files. + $tmpdir_clean_is_error = $false } else { $temp_path = [System.IO.Path]::GetTempPath() $include_debug = $IncludeDebugInfo.IsPresent + $tmpdir_clean_is_error = $true } $temp_path = Join-Path -Path $temp_path -ChildPath ([Guid]::NewGuid().Guid) @@ -388,17 +394,13 @@ } finally { # Try to delete the temp path, if this fails and we are running - # with a module object write a warning instead of failing. + # with a module object, ignore and let it cleanup later. try { [System.IO.Directory]::Delete($temp_path, $true) } catch { - $msg = "Failed to cleanup temporary directory '$temp_path' used for compiling C# code." - if ($AnsibleModule) { - $AnsibleModule.Warn("$msg Files may still be present after the task is complete. Error: $_") - } - else { - throw "$msg Error: $_" + if ($tmpdir_clean_is_error) { + throw "Failed to cleanup temporary directory '$temp_path' used for compiling C# code. Error: $_" } } } diff -Nru ansible-core-2.19.1/lib/ansible/module_utils/urls.py ansible-core-2.19.4/lib/ansible/module_utils/urls.py --- ansible-core-2.19.1/lib/ansible/module_utils/urls.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/module_utils/urls.py 2025-11-04 23:27:03.000000000 +0000 @@ -1358,7 +1358,8 @@ def fetch_file(module, url, data=None, headers=None, method=None, use_proxy=True, force=False, last_mod_time=None, timeout=10, - unredirected_headers=None, decompress=True, ciphers=None): + unredirected_headers=None, decompress=True, ciphers=None, + ca_path=None, cookies=None): """Download and save a file via HTTP(S) or FTP (needs the module as parameter). This is basically a wrapper around fetch_url(). @@ -1375,6 +1376,8 @@ :kwarg unredirected_headers: (optional) A list of headers to not attach on a redirected request :kwarg decompress: (optional) Whether to attempt to decompress gzip content-encoded responses :kwarg ciphers: (optional) List of ciphers to use + :kwarg ca_path: (optional) Path to CA bundle + :kwarg cookies: (optional) CookieJar object to send with the request :returns: A string, the path to the downloaded file. """ @@ -1386,7 +1389,8 @@ module.add_cleanup_file(fetch_temp_file.name) try: rsp, info = fetch_url(module, url, data, headers, method, use_proxy, force, last_mod_time, timeout, - unredirected_headers=unredirected_headers, decompress=decompress, ciphers=ciphers) + unredirected_headers=unredirected_headers, decompress=decompress, ciphers=ciphers, + ca_path=ca_path, cookies=cookies) if not rsp or (rsp.code and rsp.code >= 400): module.fail_json(msg="Failure downloading %s, %s" % (url, info['msg'])) data = rsp.read(bufsize) diff -Nru ansible-core-2.19.1/lib/ansible/modules/package_facts.py ansible-core-2.19.4/lib/ansible/modules/package_facts.py --- ansible-core-2.19.1/lib/ansible/modules/package_facts.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/modules/package_facts.py 2025-11-04 23:27:03.000000000 +0000 @@ -278,11 +278,11 @@ return self._lib.TransactionSet().dbMatch() def get_package_details(self, package): - return dict(name=package[self._lib.RPMTAG_NAME], - version=package[self._lib.RPMTAG_VERSION], - release=package[self._lib.RPMTAG_RELEASE], - epoch=package[self._lib.RPMTAG_EPOCH], - arch=package[self._lib.RPMTAG_ARCH],) + return dict(name=to_text(package[self._lib.RPMTAG_NAME]), + version=to_text(package[self._lib.RPMTAG_VERSION]), + release=to_text(package[self._lib.RPMTAG_RELEASE]), + epoch=to_text(package[self._lib.RPMTAG_EPOCH]), + arch=to_text(package[self._lib.RPMTAG_ARCH]),) class APT(RespawningLibMgr): diff -Nru ansible-core-2.19.1/lib/ansible/parsing/mod_args.py ansible-core-2.19.4/lib/ansible/parsing/mod_args.py --- ansible-core-2.19.1/lib/ansible/parsing/mod_args.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/parsing/mod_args.py 2025-11-04 23:27:03.000000000 +0000 @@ -130,6 +130,7 @@ # HACK: why are these not FieldAttributes on task with a post-validate to check usage? self._task_attrs.update(['local_action', 'static']) self._task_attrs = frozenset(self._task_attrs) + self._resolved_action = None def _split_module_string(self, module_string: str) -> tuple[str, str]: """ @@ -344,6 +345,8 @@ raise e is_action_candidate = context.resolved and bool(context.redirect_list) + if is_action_candidate: + self._resolved_action = context.resolved_fqcn if is_action_candidate: # finding more than one module name is a problem diff -Nru ansible-core-2.19.1/lib/ansible/playbook/block.py ansible-core-2.19.4/lib/ansible/playbook/block.py --- ansible-core-2.19.1/lib/ansible/playbook/block.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/playbook/block.py 2025-11-04 23:27:03.000000000 +0000 @@ -17,7 +17,6 @@ from __future__ import annotations -import ansible.constants as C from ansible.errors import AnsibleParserError from ansible.module_utils.common.sentinel import Sentinel from ansible.playbook.attribute import NonInheritableFieldAttribute @@ -376,8 +375,7 @@ filtered_block = evaluate_block(task) if filtered_block.has_tasks(): tmp_list.append(filtered_block) - elif ((task.action in C._ACTION_META and task.implicit) or - task.evaluate_tags(self._play.only_tags, self._play.skip_tags, all_vars=all_vars)): + elif task.evaluate_tags(self._play.only_tags, self._play.skip_tags, all_vars=all_vars): tmp_list.append(task) return tmp_list diff -Nru ansible-core-2.19.1/lib/ansible/playbook/helpers.py ansible-core-2.19.4/lib/ansible/playbook/helpers.py --- ansible-core-2.19.1/lib/ansible/playbook/helpers.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/playbook/helpers.py 2025-11-04 23:27:03.000000000 +0000 @@ -165,17 +165,29 @@ subdir = 'tasks' if use_handlers: subdir = 'handlers' + try: + include_target = templar.template(task.args['_raw_params']) + except AnsibleUndefinedVariable as ex: + raise AnsibleParserError( + message=f"Error when evaluating variable in import path {task.args['_raw_params']!r}.", + help_text="When using static imports, ensure that any variables used in their names are defined in vars/vars_files\n" + "or extra-vars passed in from the command line. Static imports cannot use variables from facts or inventory\n" + "sources like group or host vars.", + obj=task_ds, + ) from ex + # FIXME this appears to be (almost?) duplicate code as in IncludedFile for include_tasks while parent_include is not None: if not isinstance(parent_include, TaskInclude): parent_include = parent_include._parent continue - parent_include.post_validate(templar=templar) - parent_include_dir = os.path.dirname(parent_include.args.get('_raw_params')) + if isinstance(parent_include, IncludeRole): + parent_include_dir = parent_include._role_path + else: + parent_include_dir = os.path.dirname(templar.template(parent_include.args.get('_raw_params'))) if cumulative_path is None: cumulative_path = parent_include_dir elif not os.path.isabs(cumulative_path): cumulative_path = os.path.join(parent_include_dir, cumulative_path) - include_target = templar.template(task.args['_raw_params']) if task._role: new_basedir = os.path.join(task._role._role_path, subdir, cumulative_path) include_file = loader.path_dwim_relative(new_basedir, subdir, include_target) @@ -189,16 +201,6 @@ parent_include = parent_include._parent if not found: - try: - include_target = templar.template(task.args['_raw_params']) - except AnsibleUndefinedVariable as ex: - raise AnsibleParserError( - message=f"Error when evaluating variable in import path {task.args['_raw_params']!r}.", - help_text="When using static imports, ensure that any variables used in their names are defined in vars/vars_files\n" - "or extra-vars passed in from the command line. Static imports cannot use variables from facts or inventory\n" - "sources like group or host vars.", - obj=task_ds, - ) from ex if task._role: include_file = loader.path_dwim_relative(task._role._role_path, subdir, include_target) else: diff -Nru ansible-core-2.19.1/lib/ansible/playbook/play.py ansible-core-2.19.4/lib/ansible/playbook/play.py --- ansible-core-2.19.1/lib/ansible/playbook/play.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/playbook/play.py 2025-11-04 23:27:03.000000000 +0000 @@ -303,23 +303,13 @@ t = Task(block=flush_block) t.action = 'meta' - t.resolved_action = 'ansible.builtin.meta' + t._resolved_action = 'ansible.builtin.meta' t.args['_raw_params'] = 'flush_handlers' t.implicit = True t.set_loader(self._loader) + t.tags = ['always'] - if self.tags: - # Avoid calling flush_handlers in case the whole play is skipped on tags, - # this could be performance improvement since calling flush_handlers on - # large inventories could be expensive even if no hosts are notified - # since we call flush_handlers per host. - # Block.filter_tagged_tasks ignores evaluating tags on implicit meta - # tasks so we need to explicitly call Task.evaluate_tags here. - t.tags = self.tags - if t.evaluate_tags(self.only_tags, self.skip_tags, all_vars=self.vars): - flush_block.block = [t] - else: - flush_block.block = [t] + flush_block.block = [t] # NOTE keep flush_handlers tasks even if a section has no regular tasks, # there may be notified handlers from the previous section diff -Nru ansible-core-2.19.1/lib/ansible/playbook/task.py ansible-core-2.19.4/lib/ansible/playbook/task.py --- ansible-core-2.19.1/lib/ansible/playbook/task.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/playbook/task.py 2025-11-04 23:27:03.000000000 +0000 @@ -41,7 +41,7 @@ from ansible.playbook.taggable import Taggable from ansible._internal import _task from ansible._internal._templating import _marker_behaviors -from ansible._internal._templating._jinja_bits import is_possibly_all_template +from ansible._internal._templating._jinja_bits import is_possibly_all_template, is_possibly_template from ansible._internal._templating._engine import TemplateEngine, TemplateOptions from ansible.utils.collection_loader import AnsibleCollectionConfig from ansible.utils.display import Display @@ -101,7 +101,7 @@ self._role = role self._parent = None self.implicit = False - self.resolved_action: str | None = None + self._resolved_action: str | None = None if task_include: self._parent = task_include @@ -110,6 +110,38 @@ super(Task, self).__init__() + _resolved_action_warning = ( + "A plugin is sampling the task's resolved_action when it is not resolved. " + "This can be caused by callback plugins using the resolved_action attribute too " + "early (such as in v2_playbook_on_task_start for a task using the action/local_action " + "keyword), or too late (such as in v2_runner_on_ok for a task with a loop). " + "To maximize compatibility with user features, callback plugins should " + "only use this attribute in v2_runner_on_ok/v2_runner_on_failed for tasks " + "without a loop, and v2_runner_item_on_ok/v2_runner_item_on_failed otherwise." + ) + + @property + def resolved_action(self) -> str | None: + """The templated and resolved FQCN of the task action or None. + + If the action is a template, callback plugins can only use this value in certain methods. + - v2_runner_on_ok and v2_runner_on_failed if there's no task loop + - v2_runner_item_on_ok and v2_runner_item_on_failed if there is a task loop + """ + # Consider deprecating this because it's difficult to use? + # Moving it to the task result would improve the no-loop limitation on v2_runner_on_ok + # but then wouldn't be accessible to v2_playbook_on_task_start, *_on_skipped, etc. + if self._resolved_action is not None: + return self._resolved_action + if not is_possibly_template(self.action): + try: + return self._resolve_action(self.action) + except AnsibleParserError: + display.warning(self._resolved_action_warning, obj=self.action) + else: + display.warning(self._resolved_action_warning, obj=self.action) + return None + def get_name(self, include_role_fqcn=True): """ return the name of the task """ @@ -168,7 +200,7 @@ else: module_or_action_context = action_context.plugin_load_context - self.resolved_action = module_or_action_context.resolved_fqcn + self._resolved_action = module_or_action_context.resolved_fqcn action_type: type[ActionBase] = action_context.object @@ -282,6 +314,9 @@ # But if it wasn't, we can add the yaml object now to get more detail raise AnsibleParserError("Error parsing task arguments.", obj=ds) from ex + if args_parser._resolved_action is not None: + self._resolved_action = args_parser._resolved_action + new_ds['action'] = action new_ds['args'] = args new_ds['delegate_to'] = delegate_to @@ -465,7 +500,7 @@ new_me._role = self._role new_me.implicit = self.implicit - new_me.resolved_action = self.resolved_action + new_me._resolved_action = self._resolved_action new_me._uuid = self._uuid return new_me @@ -482,7 +517,7 @@ data['role'] = self._role.serialize() data['implicit'] = self.implicit - data['resolved_action'] = self.resolved_action + data['_resolved_action'] = self._resolved_action return data @@ -513,7 +548,7 @@ del data['role'] self.implicit = data.get('implicit', False) - self.resolved_action = data.get('resolved_action') + self._resolved_action = data.get('_resolved_action') super(Task, self).deserialize(data) @@ -591,7 +626,7 @@ def dump_attrs(self): """Override to smuggle important non-FieldAttribute values back to the controller.""" attrs = super().dump_attrs() - attrs.update(resolved_action=self.resolved_action) + attrs.update(_resolved_action=self._resolved_action) return attrs def _resolve_conditional( diff -Nru ansible-core-2.19.1/lib/ansible/plugins/connection/psrp.py ansible-core-2.19.4/lib/ansible/plugins/connection/psrp.py --- ansible-core-2.19.1/lib/ansible/plugins/connection/psrp.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/plugins/connection/psrp.py 2025-11-04 23:27:03.000000000 +0000 @@ -331,7 +331,7 @@ from pypsrp.host import PSHost, PSHostUserInterface from pypsrp.powershell import PowerShell, RunspacePool from pypsrp.wsman import WSMan - from requests.exceptions import ConnectionError, ConnectTimeout + from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout except ImportError as err: HAS_PYPSRP = False PYPSRP_IMP_ERR = err @@ -479,11 +479,16 @@ pwsh_in_data = in_data display.vvv(u"PSRP: EXEC %s" % script, host=self._psrp_host) - rc, stdout, stderr = self._exec_psrp_script( - script=script, - input_data=pwsh_in_data.splitlines() if pwsh_in_data else None, - arguments=script_args, - ) + try: + rc, stdout, stderr = self._exec_psrp_script( + script=script, + input_data=pwsh_in_data.splitlines() if pwsh_in_data else None, + arguments=script_args, + ) + except ReadTimeout as e: + raise AnsibleConnectionFailure( + "HTTP read timeout during PSRP script execution" + ) from e return rc, stdout, stderr def put_file(self, in_path: str, out_path: str) -> None: diff -Nru ansible-core-2.19.1/lib/ansible/plugins/filter/core.py ansible-core-2.19.4/lib/ansible/plugins/filter/core.py --- ansible-core-2.19.1/lib/ansible/plugins/filter/core.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/plugins/filter/core.py 2025-11-04 23:27:03.000000000 +0000 @@ -221,6 +221,7 @@ return items +@accept_args_markers def ternary(value, true_val, false_val, none_val=None): """ value ? true_val : false_val """ if value is None and none_val is not None: diff -Nru ansible-core-2.19.1/lib/ansible/plugins/inventory/script.py ansible-core-2.19.4/lib/ansible/plugins/inventory/script.py --- ansible-core-2.19.1/lib/ansible/plugins/inventory/script.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/plugins/inventory/script.py 2025-11-04 23:27:03.000000000 +0000 @@ -256,9 +256,10 @@ group = self.inventory.add_group(group) if not isinstance(data, dict): + original_type = native_type_name(data) data = {'hosts': data} display.deprecated( - msg=f"Group {group!r} was converted to {native_type_name(dict)!r} from {native_type_name(data)!r}.", + msg=f"Group {group!r} was converted to {native_type_name(dict)!r} from {original_type!r}.", version='2.23', obj=origin, ) diff -Nru ansible-core-2.19.1/lib/ansible/plugins/list.py ansible-core-2.19.4/lib/ansible/plugins/list.py --- ansible-core-2.19.1/lib/ansible/plugins/list.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/plugins/list.py 2025-11-04 23:27:03.000000000 +0000 @@ -105,18 +105,25 @@ ]): continue + resource_dir = to_native(os.path.dirname(full_path)) + resource_name = get_composite_name(collection, plugin, resource_dir, depth) + if ptype in ('test', 'filter'): + # NOTE: pass the composite resource to ensure any relative + # imports it contains are interpreted in the correct context + if collection: + resource_name = '.'.join(resource_name.split('.')[2:]) try: - file_plugins = _list_j2_plugins_from_file(collection, full_path, ptype, plugin) + file_plugins = _list_j2_plugins_from_file(collection, full_path, ptype, resource_name) except KeyError as e: display.warning('Skipping file %s: %s' % (full_path, to_native(e))) continue for plugin in file_plugins: - plugin_name = get_composite_name(collection, plugin.ansible_name, os.path.dirname(to_native(full_path)), depth) + plugin_name = get_composite_name(collection, plugin.ansible_name, resource_dir, depth) plugins[plugin_name] = full_path else: - plugin_name = get_composite_name(collection, plugin, os.path.dirname(to_native(full_path)), depth) + plugin_name = resource_name plugins[plugin_name] = full_path else: display.debug("Skip listing plugins in '{0}' as it is not a directory".format(path)) diff -Nru ansible-core-2.19.1/lib/ansible/plugins/loader.py ansible-core-2.19.4/lib/ansible/plugins/loader.py --- ansible-core-2.19.1/lib/ansible/plugins/loader.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/plugins/loader.py 2025-11-04 23:27:03.000000000 +0000 @@ -36,6 +36,7 @@ from ansible.utils.display import Display from ansible.utils.plugin_docs import add_fragments from ansible._internal._datatag import _tags +from ansible._internal import _display_utils from . import _AnsiblePluginInfoMixin from .filter import AnsibleJinja2Filter @@ -606,7 +607,7 @@ warning_text = tombstone.get('warning_text') or '' warning_plugin_type = "module" if self.type == "modules" else f'{self.type} plugin' warning_text = f'The {fq_name!r} {warning_plugin_type} has been removed.{" " if warning_text else ""}{warning_text}' - removed_msg = display._get_deprecation_message_with_plugin_info( + removed_msg = _display_utils.get_deprecation_message_with_plugin_info( msg=warning_text, version=removal_version, date=removal_date, @@ -1411,7 +1412,7 @@ removal_version = tombstone_entry.get('removal_version') warning_text = f'The {key!r} {self.type} plugin has been removed.{" " if warning_text else ""}{warning_text}' - exc_msg = display._get_deprecation_message_with_plugin_info( + exc_msg = _display_utils.get_deprecation_message_with_plugin_info( msg=warning_text, version=removal_version, date=removal_date, diff -Nru ansible-core-2.19.1/lib/ansible/plugins/lookup/config.py ansible-core-2.19.4/lib/ansible/plugins/lookup/config.py --- ansible-core-2.19.1/lib/ansible/plugins/lookup/config.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/plugins/lookup/config.py 2025-11-04 23:27:03.000000000 +0000 @@ -88,31 +88,6 @@ from ansible.plugins.lookup import LookupBase -def _get_plugin_config(pname, ptype, config, variables): - # plugin creates settings on load, this is cached so not too expensive to redo - loader = getattr(plugin_loader, '%s_loader' % ptype) - p = loader.get(pname, class_only=True) - - if p is None: - raise AnsibleError(f"Unable to load {ptype} plugin {pname!r}.") - - result, origin = C.config.get_config_value_and_origin(config, plugin_type=ptype, plugin_name=p._load_name, variables=variables) - - return result, origin - - -def _get_global_config(config): - try: - result = getattr(C, config) - except AttributeError: - raise AnsibleUndefinedConfigEntry(f"Setting {config!r} does not exist.") from None - - if callable(result): - raise ValueError(f"Invalid setting {config!r} attempted.") - - return result - - class LookupModule(LookupBase): def run(self, terms, variables=None, **kwargs): @@ -135,18 +110,26 @@ result = Sentinel origin = None + + # plugin creates settings on load, we ensure that happens here + if pname: + # this is cached so not too expensive + loader = getattr(plugin_loader, f'{ptype}_loader') + p = loader.get(pname, class_only=True) + if p is None: + raise AnsibleError(f"Unable to load {ptype} plugin {pname!r}.") try: - if pname: - result, origin = _get_plugin_config(pname, ptype, term, variables) - else: - result = _get_global_config(term) - except AnsibleUndefinedConfigEntry: - if missing == 'error': - raise - elif missing == 'warn': - self._display.warning(f"Skipping, did not find setting {term!r}.") - elif missing == 'skip': - pass # this is not needed, but added to have all 3 options stated + result, origin = C.config.get_config_value_and_origin(term, plugin_type=ptype, plugin_name=pname, variables=variables) + except AnsibleUndefinedConfigEntry as e: + match missing: + case 'error': + raise + case 'skip': + pass + case 'warn': + self._display.error_as_warning(msg=f"Skipping {term}.", exception=e) + case _: + raise AnsibleError(f"Invalid option for error handling, missing must be error, warn or skip, got: {missing}.") from e if result is not Sentinel: if show_origin: diff -Nru ansible-core-2.19.1/lib/ansible/plugins/strategy/__init__.py ansible-core-2.19.4/lib/ansible/plugins/strategy/__init__.py --- ansible-core-2.19.1/lib/ansible/plugins/strategy/__init__.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/plugins/strategy/__init__.py 2025-11-04 23:27:03.000000000 +0000 @@ -900,7 +900,7 @@ display.warning("%s task does not support when conditional" % task_name) def _execute_meta(self, task: Task, play_context, iterator, target_host: Host): - task.resolved_action = 'ansible.builtin.meta' # _post_validate_args is never called for meta actions, so resolved_action hasn't been set + task._resolved_action = 'ansible.builtin.meta' # _post_validate_args is never called for meta actions, so resolved_action hasn't been set # meta tasks store their args in the _raw_params field of args, # since they do not use k=v pairs, so get that diff -Nru ansible-core-2.19.1/lib/ansible/release.py ansible-core-2.19.4/lib/ansible/release.py --- ansible-core-2.19.1/lib/ansible/release.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/release.py 2025-11-04 23:27:03.000000000 +0000 @@ -17,6 +17,6 @@ from __future__ import annotations -__version__ = '2.19.1' +__version__ = '2.19.4' __author__ = 'Ansible, Inc.' __codename__ = "What Is and What Should Never Be" diff -Nru ansible-core-2.19.1/lib/ansible/utils/display.py ansible-core-2.19.4/lib/ansible/utils/display.py --- ansible-core-2.19.1/lib/ansible/utils/display.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/lib/ansible/utils/display.py 2025-11-04 23:27:03.000000000 +0000 @@ -18,7 +18,6 @@ from __future__ import annotations import contextlib -import dataclasses try: import curses @@ -52,8 +51,8 @@ from ansible.constants import config from ansible.errors import AnsibleAssertionError, AnsiblePromptInterrupt, AnsiblePromptNoninteractive, AnsibleError from ansible._internal._errors import _error_utils, _error_factory -from ansible._internal import _event_formatting -from ansible.module_utils._internal import _ambient_context, _deprecator, _messages +from ansible._internal import _display_utils +from ansible.module_utils._internal import _deprecator, _messages from ansible.module_utils.common.text.converters import to_bytes, to_text from ansible.module_utils.datatag import deprecator_from_collection_name from ansible._internal._datatag._tags import TrustedAsTemplate @@ -100,6 +99,17 @@ _traceback._is_traceback_enabled = _is_controller_traceback_enabled +def _deprecation_warnings_enabled() -> bool: + """Return True if deprecation warnings are enabled for the current calling context, otherwise False.""" + # DTFIX-FUTURE: move this capability into config using an AmbientContext-derived TaskContext (once it exists) + if warning_ctx := _display_utils.DeferredWarningContext.current(optional=True): + variables = warning_ctx._variables + else: + variables = None + + return C.config.get_config_value('DEPRECATION_WARNINGS', variables=variables) + + def get_text_width(text: str) -> int: """Function that utilizes ``wcswidth`` or ``wcwidth`` to determine the number of columns used to display a text string. @@ -582,7 +592,7 @@ version="2.23", ) - msg = self._get_deprecation_message_with_plugin_info( + msg = _display_utils.get_deprecation_message_with_plugin_info( msg=msg, version=version, removed=removed, @@ -597,70 +607,6 @@ return msg - def _get_deprecation_message_with_plugin_info( - self, - *, - msg: str, - version: str | None, - removed: bool = False, - date: str | None, - deprecator: _messages.PluginInfo | None, - ) -> str: - """Internal use only. Return a deprecation message and help text for display.""" - # DTFIX-FUTURE: the logic for omitting date/version doesn't apply to the payload, so it shows up in vars in some cases when it should not - - if removed: - removal_fragment = 'This feature was removed' - else: - removal_fragment = 'This feature will be removed' - - if not deprecator or not deprecator.type: - # indeterminate has no resolved_name or type - # collections have a resolved_name but no type - collection = deprecator.resolved_name if deprecator else None - plugin_fragment = '' - elif deprecator.resolved_name == 'ansible.builtin': - # core deprecations from base classes (the API) have no plugin name, only 'ansible.builtin' - plugin_type_name = str(deprecator.type) if deprecator.type is _messages.PluginType.MODULE else f'{deprecator.type} plugin' - - collection = deprecator.resolved_name - plugin_fragment = f'the {plugin_type_name} API' - else: - parts = deprecator.resolved_name.split('.') - plugin_name = parts[-1] - plugin_type_name = str(deprecator.type) if deprecator.type is _messages.PluginType.MODULE else f'{deprecator.type} plugin' - - collection = '.'.join(parts[:2]) if len(parts) > 2 else None - plugin_fragment = f'{plugin_type_name} {plugin_name!r}' - - if collection and plugin_fragment: - plugin_fragment += ' in' - - if collection == 'ansible.builtin': - collection_fragment = 'ansible-core' - elif collection: - collection_fragment = f'collection {collection!r}' - else: - collection_fragment = '' - - if not collection: - when_fragment = 'in the future' if not removed else '' - elif date: - when_fragment = f'in a release after {date}' - elif version: - when_fragment = f'version {version}' - else: - when_fragment = 'in a future release' if not removed else '' - - if plugin_fragment or collection_fragment: - from_fragment = 'from' - else: - from_fragment = '' - - deprecation_msg = ' '.join(f for f in [removal_fragment, from_fragment, plugin_fragment, collection_fragment, when_fragment] if f) + '.' - - return _join_sentences(msg, deprecation_msg) - @staticmethod def _deduplicate(msg: str, messages: set[str]) -> bool: """ @@ -729,7 +675,7 @@ _skip_stackwalk = True if removed: - formatted_msg = self._get_deprecation_message_with_plugin_info( + formatted_msg = _display_utils.get_deprecation_message_with_plugin_info( msg=msg, version=version, removed=removed, @@ -756,7 +702,7 @@ deprecator=deprecator, ) - if warning_ctx := _DeferredWarningContext.current(optional=True): + if warning_ctx := _display_utils.DeferredWarningContext.current(optional=True): warning_ctx.capture(deprecation) return @@ -769,12 +715,12 @@ # This is the post-proxy half of the `deprecated` implementation. # Any logic that must occur in the primary controller process needs to be implemented here. - if not _DeferredWarningContext.deprecation_warnings_enabled(): + if not _deprecation_warnings_enabled(): return self.warning('Deprecation warnings can be disabled by setting `deprecation_warnings=False` in ansible.cfg.') - msg = _format_message(warning, _traceback.is_traceback_enabled(_traceback.TracebackEvent.DEPRECATED)) + msg = _display_utils.format_message(warning, _traceback.is_traceback_enabled(_traceback.TracebackEvent.DEPRECATED)) msg = f'[DEPRECATION WARNING]: {msg}' if self._deduplicate(msg, self._deprecations): @@ -812,7 +758,7 @@ ), ) - if warning_ctx := _DeferredWarningContext.current(optional=True): + if warning_ctx := _display_utils.DeferredWarningContext.current(optional=True): warning_ctx.capture(warning) return @@ -825,7 +771,7 @@ # This is the post-proxy half of the `warning` implementation. # Any logic that must occur in the primary controller process needs to be implemented here. - msg = _format_message(warning, _traceback.is_traceback_enabled(_traceback.TracebackEvent.WARNING)) + msg = _display_utils.format_message(warning, _traceback.is_traceback_enabled(_traceback.TracebackEvent.WARNING)) msg = f"[WARNING]: {msg}" if self._deduplicate(msg, self._warns): @@ -915,7 +861,7 @@ event=event, ) - if warning_ctx := _DeferredWarningContext.current(optional=True): + if warning_ctx := _display_utils.DeferredWarningContext.current(optional=True): warning_ctx.capture(warning) return @@ -952,7 +898,7 @@ # This is the post-proxy half of the `error` implementation. # Any logic that must occur in the primary controller process needs to be implemented here. - msg = _format_message(error, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR)) + msg = _display_utils.format_message(error, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR)) msg = f'[ERROR]: {msg}' if self._deduplicate(msg, self._errors): @@ -1173,92 +1119,6 @@ _display = Display() -class _DeferredWarningContext(_ambient_context.AmbientContextBase): - """ - Calls to `Display.warning()` and `Display.deprecated()` within this context will cause the resulting warnings to be captured and not displayed. - The intended use is for task-initiated warnings to be recorded with the task result, which makes them visible to registered results, callbacks, etc. - The active display callback is responsible for communicating any warnings to the user. - """ - - # DTFIX-FUTURE: once we start implementing nested scoped contexts for our own bookkeeping, this should be an interface facade that forwards to the nearest - # context that actually implements the warnings collection capability - - def __init__(self, *, variables: dict[str, object]) -> None: - self._variables = variables # DTFIX-FUTURE: move this to an AmbientContext-derived TaskContext (once it exists) - self._deprecation_warnings: list[_messages.DeprecationSummary] = [] - self._warnings: list[_messages.WarningSummary] = [] - self._seen: set[_messages.WarningSummary] = set() - - @classmethod - def deprecation_warnings_enabled(cls) -> bool: - """Return True if deprecation warnings are enabled for the current calling context, otherwise False.""" - # DTFIX-FUTURE: move this capability into config using an AmbientContext-derived TaskContext (once it exists) - if warning_ctx := cls.current(optional=True): - variables = warning_ctx._variables - else: - variables = None - - return C.config.get_config_value('DEPRECATION_WARNINGS', variables=variables) - - def capture(self, warning: _messages.WarningSummary) -> None: - """Add the warning/deprecation to the context if it has not already been seen by this context.""" - if warning in self._seen: - return - - self._seen.add(warning) - - if isinstance(warning, _messages.DeprecationSummary): - self._deprecation_warnings.append(warning) - else: - self._warnings.append(warning) - - def get_warnings(self) -> list[_messages.WarningSummary]: - """Return a list of the captured non-deprecation warnings.""" - # DTFIX-FUTURE: return a read-only list proxy instead - return self._warnings - - def get_deprecation_warnings(self) -> list[_messages.DeprecationSummary]: - """Return a list of the captured deprecation warnings.""" - # DTFIX-FUTURE: return a read-only list proxy instead - return self._deprecation_warnings - - -def _join_sentences(first: str | None, second: str | None) -> str: - """Join two sentences together.""" - first = (first or '').strip() - second = (second or '').strip() - - if first and first[-1] not in ('!', '?', '.'): - first += '.' - - if second and second[-1] not in ('!', '?', '.'): - second += '.' - - if first and not second: - return first - - if not first and second: - return second - - return ' '.join((first, second)) - - -def _format_message(summary: _messages.SummaryBase, include_traceback: bool) -> str: - if isinstance(summary, _messages.DeprecationSummary): - deprecation_message = _display._get_deprecation_message_with_plugin_info( - msg=summary.event.msg, - version=summary.version, - date=summary.date, - deprecator=summary.deprecator, - ) - - event = dataclasses.replace(summary.event, msg=deprecation_message) - else: - event = summary.event - - return _event_formatting.format_event(event, include_traceback) - - def _report_config_warnings(deprecator: _messages.PluginInfo) -> None: """Called by config to report warnings/deprecations collected during a config parse.""" while config._errors: diff -Nru ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/in_subdir.py ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/in_subdir.py --- ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/in_subdir.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/in_subdir.py 2025-11-04 23:27:03.000000000 +0000 @@ -2,7 +2,9 @@ from __future__ import annotations -from ansible.utils.display import Display +from ansible_collections.testns.testcol.plugins.module_utils import Display +# Test for https://github.com/ansible/ansible/issues/85754 +from ...module_utils import Display display = Display() diff -Nru ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/nested.yml ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/nested.yml --- ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/nested.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/nested.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,7 @@ +DOCUMENTATION: + description: filter plugin in a subdirectory + author: ansible-core + options: + _input: + description: input data, which does nothing + type: raw diff -Nru ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/module_utils/__init__.py ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/module_utils/__init__.py --- ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/module_utils/__init__.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/module_utils/__init__.py 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,3 @@ +from __future__ import annotations + +from ansible.utils.display import Display diff -Nru ansible-core-2.19.1/test/integration/targets/ansible-doc/runme.sh ansible-core-2.19.4/test/integration/targets/ansible-doc/runme.sh --- ansible-core-2.19.1/test/integration/targets/ansible-doc/runme.sh 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/ansible-doc/runme.sh 2025-11-04 23:27:03.000000000 +0000 @@ -211,6 +211,13 @@ output=$(ANSIBLE_LIBRARY='./nolibrary' ansible-doc --metadata-dump --playbook-dir broken-docs testns.testcol 2>&1 | grep -c 'ERROR' || true) test "${output}" -eq 1 +# ensure --metadata-dump does not crash if the ansible_collections is nested (https://github.com/ansible/ansible/issues/84909) +testdir="$(pwd)" +pbdir="collections/ansible_collections/testns/testcol/playbooks" +cd "$pbdir" +ANSIBLE_COLLECTIONS_PATH="$testdir/$pbdir/collections" ansible-doc -vvv --metadata-dump --no-fail-on-errors +cd "$testdir" + echo "test doc list on broken role metadata" # ensure that role doc does not fail when --no-fail-on-errors is supplied ANSIBLE_LIBRARY='./nolibrary' ansible-doc --no-fail-on-errors --playbook-dir broken-docs testns.testcol.testrole -t role 1>/dev/null 2>&1 @@ -243,6 +250,7 @@ [ "$(ansible-doc -t test --playbook-dir ./ testns.testcol.yolo| wc -l)" -gt "0" ] [ "$(ansible-doc -t filter --playbook-dir ./ donothing| wc -l)" -gt "0" ] [ "$(ansible-doc -t filter --playbook-dir ./ ansible.legacy.donothing| wc -l)" -gt "0" ] +[ "$(ansible-doc -t filter --playbook-dir ./ testns.testcol.filter_subdir.nested| wc -l)" -gt "0" ] echo "testing no docs and no sidecar" ansible-doc -t filter --playbook-dir ./ nodocs 2>&1| grep "${GREP_OPTS[@]}" -c 'missing documentation' || true diff -Nru ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin/callback_plugins/display_resolved_action.py ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/callback_plugins/display_resolved_action.py --- ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin/callback_plugins/display_resolved_action.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/callback_plugins/display_resolved_action.py 2025-11-04 23:27:03.000000000 +0000 @@ -9,6 +9,12 @@ short_description: Displays the requested and resolved actions at the end of a playbook. description: - Displays the requested and resolved actions in the format "requested == resolved". + options: + test_on_task_start: + description: Test using task.resolved_action before it is reliably resolved. + default: False + env: + - name: ANSIBLE_TEST_ON_TASK_START requirements: - Enable in configuration. """ @@ -25,11 +31,14 @@ def __init__(self, *args, **kwargs): super(CallbackModule, self).__init__(*args, **kwargs) - self.requested_to_resolved = {} - def v2_runner_on_ok(self, result): - self.requested_to_resolved[result.task.action] = result.task.resolved_action + def v2_playbook_on_task_start(self, task, is_conditional): + if self.get_option("test_on_task_start"): + self._display.display(f"v2_playbook_on_task_start: {task.action} == {task.resolved_action}") + + def v2_runner_item_on_ok(self, result): + self._display.display(f"v2_runner_item_on_ok: {result.task.action} == {result.task.resolved_action}") - def v2_playbook_on_stats(self, stats): - for requested, resolved in self.requested_to_resolved.items(): - self._display.display("%s == %s" % (requested, resolved), screen_only=True) + def v2_runner_on_ok(self, result): + if not result.task.loop: + self._display.display(f"v2_runner_on_ok: {result.task.action} == {result.task.resolved_action}") diff -Nru ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin/dynamic_action.yml ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/dynamic_action.yml --- ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin/dynamic_action.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/dynamic_action.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,10 @@ +--- +- hosts: all + gather_facts: no + tasks: + - name: Run dynamic action + action: "{{ inventory_hostname }}" + + - name: Run dynamic action in loop + action: "{{ inventory_hostname }}" + loop: [1] diff -Nru ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin/unqualified.yml ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/unqualified.yml --- ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin/unqualified.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/unqualified.yml 2025-11-04 23:27:03.000000000 +0000 @@ -4,5 +4,5 @@ tasks: - legacy_action: - legacy_module: - - debug: - - ping: + - local_action: debug + - action: ping diff -Nru ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin.sh ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin.sh --- ansible-core-2.19.1/test/integration/targets/collections/test_task_resolved_plugin.sh 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin.sh 2025-11-04 23:27:03.000000000 +0000 @@ -15,6 +15,22 @@ grep -q out.txt -e "$result" done +# Test local_action/action warning +export ANSIBLE_TEST_ON_TASK_START=True +ansible-playbook -i debug, test_task_resolved_plugin/dynamic_action.yml "$@" 2>&1 | tee out.txt +grep -q out.txt -e "A plugin is sampling the task's resolved_action when it is not resolved" +grep -q out.txt -e "v2_playbook_on_task_start: {{ inventory_hostname }} == None" +grep -q out.txt -e "v2_runner_on_ok: debug == ansible.builtin.debug" +grep -q out.txt -e "v2_runner_item_on_ok: debug == ansible.builtin.debug" + +# Test static actions don't cause a warning +ansible-playbook test_task_resolved_plugin/unqualified.yml "$@" 2>&1 | tee out.txt +grep -v out.txt -e "A plugin is sampling the task's resolved_action when it is not resolved" +for result in "${action_resolution[@]}"; do + grep -q out.txt -e "v2_playbook_on_task_start: $result" +done +unset ANSIBLE_TEST_ON_TASK_START + ansible-playbook test_task_resolved_plugin/unqualified_and_collections_kw.yml "$@" | tee out.txt action_resolution=( "legacy_action == legacy_action" diff -Nru ansible-core-2.19.1/test/integration/targets/connection_ssh/aliases ansible-core-2.19.4/test/integration/targets/connection_ssh/aliases --- ansible-core-2.19.1/test/integration/targets/connection_ssh/aliases 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/connection_ssh/aliases 2025-11-04 23:27:03.000000000 +0000 @@ -2,4 +2,5 @@ shippable/posix/group3 needs/target/connection needs/target/setup_test_user +needs/target/test_utils setup/always/setup_passlib_controller # required for setup_test_user diff -Nru ansible-core-2.19.1/test/integration/targets/connection_ssh/runme.sh ansible-core-2.19.4/test/integration/targets/connection_ssh/runme.sh --- ansible-core-2.19.1/test/integration/targets/connection_ssh/runme.sh 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/connection_ssh/runme.sh 2025-11-04 23:27:03.000000000 +0000 @@ -17,7 +17,7 @@ # ansible with timeout. If we time out, our custom prompt was successfully # searched for. It's a weird way of doing things, but it does ensure # that the flag gets passed to sshpass. - timeout 5 ansible -m ping \ + ../test_utils/scripts/timeout.py 5 -- ansible -m ping \ -e ansible_connection=ssh \ -e ansible_ssh_password_mechanism=sshpass \ -e ansible_sshpass_prompt=notThis: \ diff -Nru ansible-core-2.19.1/test/integration/targets/deprecations/runme.sh ansible-core-2.19.4/test/integration/targets/deprecations/runme.sh --- ansible-core-2.19.1/test/integration/targets/deprecations/runme.sh 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/deprecations/runme.sh 2025-11-04 23:27:03.000000000 +0000 @@ -25,8 +25,9 @@ # check for entry key deprecation including the name of the option, must be defined to trigger [ "$(ANSIBLE_CONFIG='entry_key_deprecated.cfg' ansible -m meta -a 'noop' localhost 2>&1 | grep -c "\[DEPRECATION WARNING\]: \[testing\]deprecated option.")" -eq "1" ] +# DTFIX: fix issue with x2 deprecation and wrong pllugin attribution # check for deprecation of entry itself, must be consumed to trigger -[ "$(ANSIBLE_TEST_ENTRY2=1 ansible -m debug -a 'msg={{q("config", "_Z_TEST_ENTRY_2")}}' localhost 2>&1 | grep -c 'DEPRECATION')" -eq "1" ] +[ "$(ANSIBLE_TEST_ENTRY2=1 ansible -m debug -a 'msg={{q("config", "_Z_TEST_ENTRY_2")}}' localhost 2>&1 | grep -c 'DEPRECATION')" -eq "2" ] # check for entry deprecation, just need key defined to trigger [ "$(ANSIBLE_CONFIG='entry_key_deprecated2.cfg' ansible -m meta -a 'noop' localhost 2>&1 | grep -c 'DEPRECATION')" -eq "1" ] diff -Nru ansible-core-2.19.1/test/integration/targets/filter_core/tasks/main.yml ansible-core-2.19.4/test/integration/targets/filter_core/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/filter_core/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/filter_core/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -430,6 +430,13 @@ - '123|ternary("seven", "eight") == "seven"' - '"haha"|ternary("seven", "eight") == "seven"' +- name: Verify ternary does not evaluate unused values + assert: + that: + - (false | ternary(undefined_variable, 'seven')) == (false | ternary(d.no_such_key, 'seven')) + vars: + d: {} + - name: Verify regex_escape raises on posix_extended (failure expected) set_fact: foo: '{{"]]^"|regex_escape(re_type="posix_extended")}}' diff -Nru ansible-core-2.19.1/test/integration/targets/get_url/tasks/main.yml ansible-core-2.19.4/test/integration/targets/get_url/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/get_url/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/get_url/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -396,6 +396,8 @@ src: "testserver.py" dest: "{{ remote_tmp_dir }}/testserver.py" +# NOTE: This http test server will live for only the timeout specified in "async", so all uses +# of it must be grouped relatively close together. - name: start SimpleHTTPServer for issues 27617 shell: cd {{ files_dir }} && {{ ansible_python.executable }} {{ remote_tmp_dir}}/testserver.py {{ http_port }} async: 90 @@ -578,6 +580,19 @@ - "stat_result_sha256_with_file_scheme_71420.stat.exists == true" - "stat_result_sha256_checksum_only.stat.exists == true" +- name: Test for incomplete data read (issue 85164) + get_url: + url: 'http://localhost:{{ http_port }}/incompleteRead' + dest: '{{ remote_tmp_dir }}/85164.txt' + ignore_errors: true + register: result + +- name: Assert we have an incomplete read failure + assert: + that: + - result is failed + - '"Incomplete read" in result.msg' + #https://github.com/ansible/ansible/issues/16191 - name: Test url split with no filename get_url: @@ -761,16 +776,3 @@ - assert: that: - get_dir_filename.dest == remote_tmp_dir ~ "/filename.json" - -- name: Test for incomplete data read (issue 85164) - get_url: - url: 'http://localhost:{{ http_port }}/incompleteRead' - dest: '{{ remote_tmp_dir }}/85164.txt' - ignore_errors: true - register: result - -- name: Assert we have an incomplete read failure - assert: - that: - - result is failed - - '"Incomplete read" in result.msg' diff -Nru ansible-core-2.19.1/test/integration/targets/handlers/rescue_flush_handlers.yml ansible-core-2.19.4/test/integration/targets/handlers/rescue_flush_handlers.yml --- ansible-core-2.19.1/test/integration/targets/handlers/rescue_flush_handlers.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/handlers/rescue_flush_handlers.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,16 @@ +- hosts: localhost + gather_facts: false + tasks: + - block: + - debug: + changed_when: true + notify: h1 + + - meta: flush_handlers + rescue: + - assert: + that: + - ansible_failed_task is defined + handlers: + - name: h1 + fail: diff -Nru ansible-core-2.19.1/test/integration/targets/handlers/runme.sh ansible-core-2.19.4/test/integration/targets/handlers/runme.sh --- ansible-core-2.19.1/test/integration/targets/handlers/runme.sh 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/handlers/runme.sh 2025-11-04 23:27:03.000000000 +0000 @@ -229,4 +229,13 @@ ANSIBLE_DEBUG=1 ansible-playbook tagged_play.yml --skip-tags the_whole_play "$@" 2>&1 | tee out.txt [ "$(grep out.txt -ce 'META: triggered running handlers')" = "0" ] +[ "$(grep out.txt -ce 'No handler notifications for')" = "0" ] [ "$(grep out.txt -ce 'handler_ran')" = "0" ] +[ "$(grep out.txt -ce 'handler1_ran')" = "0" ] + +ansible-playbook rescue_flush_handlers.yml "$@" + +ANSIBLE_DEBUG=1 ansible-playbook tagged_play.yml --tags task_tag "$@" 2>&1 | tee out.txt +[ "$(grep out.txt -ce 'META: triggered running handlers')" = "1" ] +[ "$(grep out.txt -ce 'handler_ran')" = "0" ] +[ "$(grep out.txt -ce 'handler1_ran')" = "1" ] diff -Nru ansible-core-2.19.1/test/integration/targets/handlers/tagged_play.yml ansible-core-2.19.4/test/integration/targets/handlers/tagged_play.yml --- ansible-core-2.19.1/test/integration/targets/handlers/tagged_play.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/handlers/tagged_play.yml 2025-11-04 23:27:03.000000000 +0000 @@ -2,9 +2,19 @@ gather_facts: false tags: the_whole_play tasks: - - command: echo + - debug: + changed_when: true notify: h + + - debug: + changed_when: true + notify: h1 + tags: task_tag handlers: - name: h debug: msg: handler_ran + + - name: h1 + debug: + msg: handler1_ran diff -Nru ansible-core-2.19.1/test/integration/targets/include_import/roles/nested_tasks/tasks/bar.yml ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/bar.yml --- ansible-core-2.19.1/test/integration/targets/include_import/roles/nested_tasks/tasks/bar.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/bar.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1 @@ +- import_tasks: does-not-exist.yml diff -Nru ansible-core-2.19.1/test/integration/targets/include_import/roles/nested_tasks/tasks/foo.yml ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/foo.yml --- ansible-core-2.19.1/test/integration/targets/include_import/roles/nested_tasks/tasks/foo.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/foo.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1 @@ +- include_tasks: bar.yml diff -Nru ansible-core-2.19.1/test/integration/targets/include_import/roles/nested_tasks/tasks/main.yml ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/include_import/roles/nested_tasks/tasks/main.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1 @@ +- import_tasks: foo.yml diff -Nru ansible-core-2.19.1/test/integration/targets/include_import/runme.sh ansible-core-2.19.4/test/integration/targets/include_import/runme.sh --- ansible-core-2.19.1/test/integration/targets/include_import/runme.sh 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/include_import/runme.sh 2025-11-04 23:27:03.000000000 +0000 @@ -155,3 +155,9 @@ test "$(grep -c 'No file specified for ansible.builtin.include_tasks' test_null_include_filename.out)" = 1 test "$(grep -c '.*/include_import/null_filename/tasks.yml:4:3.*' test_null_include_filename.out)" = 1 test "$(grep -c '\- name: invalid include_task definition' test_null_include_filename.out)" = 1 + +# https://github.com/ansible/ansible/issues/69882 +set +e +ansible-playbook test_nested_non_existent_tasks.yml 2>&1 | tee test_nested_non_existent_tasks.out +set -e +test "$(grep -c 'Could not find or access' test_nested_non_existent_tasks.out)" = 3 diff -Nru ansible-core-2.19.1/test/integration/targets/include_import/test_nested_non_existent_tasks.yml ansible-core-2.19.4/test/integration/targets/include_import/test_nested_non_existent_tasks.yml --- ansible-core-2.19.1/test/integration/targets/include_import/test_nested_non_existent_tasks.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/include_import/test_nested_non_existent_tasks.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,5 @@ +- hosts: localhost + gather_facts: false + tasks: + - include_role: + name: nested_tasks diff -Nru ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tasks/main.yml ansible-core-2.19.4/test/integration/targets/include_import_tasks_nested/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/include_import_tasks_nested/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -10,4 +10,9 @@ that: - nested_adjacent_count|int == 2 +- set_fact: + not_available_at_parsing: root + - import_tasks: "{{ role_path }}/tests/main.yml" + become: true + become_user: "{{ not_available_at_parsing }}" diff -Nru ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tests/tests_relative.yml ansible-core-2.19.4/test/integration/targets/include_import_tasks_nested/tests/tests_relative.yml --- ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tests/tests_relative.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/include_import_tasks_nested/tests/tests_relative.yml 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,6 @@ +- command: whoami + register: r + +- assert: + that: + - r.stdout == not_available_at_parsing diff -Nru ansible-core-2.19.1/test/integration/targets/inventory_script/bad_types ansible-core-2.19.4/test/integration/targets/inventory_script/bad_types --- ansible-core-2.19.1/test/integration/targets/inventory_script/bad_types 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/inventory_script/bad_types 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,13 @@ +#!/bin/sh + +echo '{ + "good_group": { + "vars": { + "test1": "value1", + "test2": "value2" + }, + "hosts": ["example1", "example2"] + }, + "bad_group": "should be list", + "_meta": {} +}' diff -Nru ansible-core-2.19.1/test/integration/targets/inventory_script/tasks/main.yml ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/inventory_script/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -1,95 +1,103 @@ -- name: run valid script output test cases - include_tasks: test_valid_inventory.yml - loop: - - mode: no_profile - show_stderr: '1' - emit_stderr: '1' - assertions: &standard_assertions - - inventory_data | length == 5 - - - inventory_data._meta | length == 2 - - inventory_data._meta.hostvars.host1.a_host1_hostvar == "avalue" - - inventory_data._meta.hostvars.host1.a_host1_hostvar is ansible._protomatter.tagged_with "TrustedAsTemplate" - - inventory_data._meta.hostvars.localhost.a_localhost_hostvar == "avalue" - - inventory_data._meta.hostvars.localhost.a_localhost_hostvar is ansible._protomatter.tagged_with "TrustedAsTemplate" - - - inventory_data.all | length == 1 - - inventory_data.all.children | symmetric_difference(["ungrouped", "group1", "empty_group", "list_as_group", "rewrite_as_host"]) | length == 0 - - - inventory_data.group1 | length == 2 - - inventory_data.group1.hosts == ["host1"] - - - inventory_data.group1.vars | length == 2 - - inventory_data.group1.vars.a_group1_groupvar == "avalue" - - inventory_data.group1.vars.a_group1_groupvar is ansible._protomatter.tagged_with "TrustedAsTemplate" - - inventory_data.group1.vars.group1_untrusted_var == "untrusted value" - - inventory_data.group1.vars.group1_untrusted_var is not ansible._protomatter.tagged_with "TrustedAsTemplate" - - - inventory_data.rewrite_as_host | length == 2 - - inventory_data.rewrite_as_host.hosts == ["rewrite_as_host"] - - inventory_data.rewrite_as_host.vars.avar == "value" - - inventory_data.rewrite_as_host.vars.avar is not ansible._protomatter.tagged_with "TrustedAsTemplate" # rewritten groups are too hard to trust and are deprecated - - inv_out.stderr is contains "Treating malformed group 'rewrite_as_host'" - - inventory_data.rewrite_as_host.vars.untrusted_var == "untrusted value" - - inventory_data.rewrite_as_host.vars.untrusted_var is not ansible._protomatter.tagged_with "TrustedAsTemplate" - - - inventory_data.ungrouped | length == 1 - - inventory_data.ungrouped.hosts == ["localhost"] - - - mode: with_profile - show_stderr: '1' - assertions: *standard_assertions - - - mode: no_hosts - assertions: - - inventory_data | length == 2 - - inventory_data._meta.hostvars == {} - - - inventory_data.all | length == 1 - - inventory_data.all.children == ["ungrouped"] - - - mode: no_meta_hostvars - assertions: - - inventory_data | length == 3 - - inventory_data._meta.hostvars | length == 1 - - inventory_data._meta.hostvars.myhost.avar == "avalue" - - inventory_data._meta.hostvars.myhost.avar is ansible._protomatter.tagged_with "TrustedAsTemplate" - - inventory_data._meta.hostvars.myhost.untrusted == "untrusted value" - - inventory_data._meta.hostvars.myhost.untrusted is not ansible._protomatter.tagged_with "TrustedAsTemplate" - - - inventory_data.all | length == 1 - - inventory_data.all.children | symmetric_difference(["ungrouped", "mygroup"]) | length == 0 - - - inventory_data.mygroup | length == 1 - - inventory_data.mygroup.hosts == ["myhost"] - - - mode: no_meta_hostvars_empty_host_result - assertions: - - inventory_data | length == 3 - - inventory_data._meta.hostvars == {} - - - inventory_data.all | length == 1 - - inventory_data.all.children | symmetric_difference(["ungrouped", "mygroup"]) | length == 0 - - - inventory_data.mygroup | length == 1 - - inventory_data.mygroup.hosts == ["myhost"] - -- name: run invalid script output test cases - include_tasks: test_broken_inventory.yml - loop: - - {mode: bad_shebang, script_name: bad_shebang, expected_error: Failed to execute inventory script command} - - {mode: non_zero_exit, expected_error: Inventory script returned non-zero exit code 1} - - {mode: invalid_utf8, expected_error: Inventory script result contained characters that cannot be interpreted as UTF-8} - - {mode: invalid_json, expected_error: Unable to get JSON decoder for inventory script result. Value could not be parsed as JSON} - - {mode: invalid_type, expected_error: Unable to get JSON decoder for inventory script result. Value is 'str' instead of 'dict'} - - {mode: invalid_meta_type, expected_error: Unable to get JSON decoder for inventory script result. Value contains '_meta' which is 'str' instead of 'dict'} - - {mode: invalid_profile_type, expected_error: Unable to get JSON decoder for inventory script result. Value contains '_meta.profile' which is 'int' instead of 'str'} - - {mode: invalid_profile_name, expected_error: Non-inventory profile 'invalid_profile' is not allowed.} - - {mode: invalid_inventory_profile_name, expected_error: Unable to get JSON decoder for inventory script result. Unknown profile name 'inventory_invalid_profile'} - - {mode: invalid_json_for_profile, expected_error: Inventory script result could not be parsed as JSON} - - {mode: invalid_meta_hostvars_type, expected_error: Value contains '_meta.hostvars' which is 'list' instead of 'dict'} - - {mode: invalid_meta_hostvars_type_for_host, expected_error: Invalid data from file, expected dictionary and got} - - {mode: invalid_group_type, expected_error: Value contains 'mygroup.hosts' which is 'NoneType' instead of 'list'} - - {mode: invalid_group_vars_type, expected_error: Value contains 'mygroup.vars' which is 'list' instead of 'dict'} - - {mode: no_meta_hostvars_host_nonzero_rc, expected_error: Inventory script returned non-zero exit code 1} - - {mode: no_meta_hostvars_host_invalid_json, expected_error: Inventory script result for host 'myhost' could not be parsed as JSON} +- name: Restrict tests to 'script' + environment: + INVENTORY_TEST_MODE: '{{ item.mode | default(omit) }}' + block: + - name: run valid script output test cases + include_tasks: test_valid_inventory.yml + loop: + - mode: no_profile + show_stderr: '1' + emit_stderr: '1' + assertions: &standard_assertions + - inventory_data | length == 5 + + - inventory_data._meta | length == 2 + - inventory_data._meta.hostvars.host1.a_host1_hostvar == "avalue" + - inventory_data._meta.hostvars.host1.a_host1_hostvar is ansible._protomatter.tagged_with "TrustedAsTemplate" + - inventory_data._meta.hostvars.localhost.a_localhost_hostvar == "avalue" + - inventory_data._meta.hostvars.localhost.a_localhost_hostvar is ansible._protomatter.tagged_with "TrustedAsTemplate" + + - inventory_data.all | length == 1 + - inventory_data.all.children | symmetric_difference(["ungrouped", "group1", "empty_group", "list_as_group", "rewrite_as_host"]) | length == 0 + + - inventory_data.group1 | length == 2 + - inventory_data.group1.hosts == ["host1"] + + - inventory_data.group1.vars | length == 2 + - inventory_data.group1.vars.a_group1_groupvar == "avalue" + - inventory_data.group1.vars.a_group1_groupvar is ansible._protomatter.tagged_with "TrustedAsTemplate" + - inventory_data.group1.vars.group1_untrusted_var == "untrusted value" + - inventory_data.group1.vars.group1_untrusted_var is not ansible._protomatter.tagged_with "TrustedAsTemplate" + + - inventory_data.rewrite_as_host | length == 2 + - inventory_data.rewrite_as_host.hosts == ["rewrite_as_host"] + - inventory_data.rewrite_as_host.vars.avar == "value" + - inventory_data.rewrite_as_host.vars.avar is not ansible._protomatter.tagged_with "TrustedAsTemplate" # rewritten groups are too hard to trust and are deprecated + - inv_out.stderr is contains "Treating malformed group 'rewrite_as_host'" + - inventory_data.rewrite_as_host.vars.untrusted_var == "untrusted value" + - inventory_data.rewrite_as_host.vars.untrusted_var is not ansible._protomatter.tagged_with "TrustedAsTemplate" + + - inventory_data.ungrouped | length == 1 + - inventory_data.ungrouped.hosts == ["localhost"] + + - mode: with_profile + show_stderr: '1' + assertions: *standard_assertions + + - mode: no_hosts + assertions: + - inventory_data | length == 2 + - inventory_data._meta.hostvars == {} + + - inventory_data.all | length == 1 + - inventory_data.all.children == ["ungrouped"] + + - mode: no_meta_hostvars + assertions: + - inventory_data | length == 3 + - inventory_data._meta.hostvars | length == 1 + - inventory_data._meta.hostvars.myhost.avar == "avalue" + - inventory_data._meta.hostvars.myhost.avar is ansible._protomatter.tagged_with "TrustedAsTemplate" + - inventory_data._meta.hostvars.myhost.untrusted == "untrusted value" + - inventory_data._meta.hostvars.myhost.untrusted is not ansible._protomatter.tagged_with "TrustedAsTemplate" + + - inventory_data.all | length == 1 + - inventory_data.all.children | symmetric_difference(["ungrouped", "mygroup"]) | length == 0 + + - inventory_data.mygroup | length == 1 + - inventory_data.mygroup.hosts == ["myhost"] + + - mode: no_meta_hostvars_empty_host_result + assertions: + - inventory_data | length == 3 + - inventory_data._meta.hostvars == {} + + - inventory_data.all | length == 1 + - inventory_data.all.children | symmetric_difference(["ungrouped", "mygroup"]) | length == 0 + + - inventory_data.mygroup | length == 1 + - inventory_data.mygroup.hosts == ["myhost"] + + - name: run invalid script output test cases + include_tasks: test_broken_inventory.yml + loop: + - {mode: bad_shebang, script_name: bad_shebang, expected_error: Failed to execute inventory script command} + - {mode: non_zero_exit, expected_error: Inventory script returned non-zero exit code 1} + - {mode: invalid_utf8, expected_error: Inventory script result contained characters that cannot be interpreted as UTF-8} + - {mode: invalid_json, expected_error: Unable to get JSON decoder for inventory script result. Value could not be parsed as JSON} + - {mode: invalid_type, expected_error: Unable to get JSON decoder for inventory script result. Value is 'str' instead of 'dict'} + - {mode: invalid_meta_type, expected_error: Unable to get JSON decoder for inventory script result. Value contains '_meta' which is 'str' instead of 'dict'} + - {mode: invalid_profile_type, expected_error: Unable to get JSON decoder for inventory script result. Value contains '_meta.profile' which is 'int' instead of 'str'} + - {mode: invalid_profile_name, expected_error: Non-inventory profile 'invalid_profile' is not allowed.} + - {mode: invalid_inventory_profile_name, expected_error: Unable to get JSON decoder for inventory script result. Unknown profile name 'inventory_invalid_profile'} + - {mode: invalid_json_for_profile, expected_error: Inventory script result could not be parsed as JSON} + - {mode: invalid_meta_hostvars_type, expected_error: Value contains '_meta.hostvars' which is 'list' instead of 'dict'} + - {mode: invalid_meta_hostvars_type_for_host, expected_error: Invalid data from file, expected dictionary and got} + - {mode: invalid_group_type, expected_error: Value contains 'mygroup.hosts' which is 'NoneType' instead of 'list'} + - {mode: invalid_group_vars_type, expected_error: Value contains 'mygroup.vars' which is 'list' instead of 'dict'} + - {mode: no_meta_hostvars_host_nonzero_rc, expected_error: Inventory script returned non-zero exit code 1} + - {mode: no_meta_hostvars_host_invalid_json, expected_error: Inventory script result for host 'myhost' could not be parsed as JSON} + - mode: bad_types + script_name: bad_types + deprecation: "Group 'bad_group' was converted to 'dict' from 'str'" # this deprecation is removed in 2.23 + expected_error: "Value contains 'bad_group.hosts' which is 'str' instead of 'list'" diff -Nru ansible-core-2.19.1/test/integration/targets/inventory_script/tasks/test_broken_inventory.yml ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/test_broken_inventory.yml --- ansible-core-2.19.1/test/integration/targets/inventory_script/tasks/test_broken_inventory.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/test_broken_inventory.yml 2025-11-04 23:27:03.000000000 +0000 @@ -2,8 +2,8 @@ shell: ansible-inventory -i {{ role_path | quote }}/{{ item.script_name | default('script_inventory_fixture.py') }} --list --export changed_when: false environment: - INVENTORY_TEST_MODE: '{{ item.mode | default(omit) }}' INVENTORY_EMIT_STDERR: '1' + ANSIBLE_DEPRECATION_WARNINGS: '{{ "deprecation" in item }}' ignore_errors: true register: inv_out @@ -12,3 +12,4 @@ that: - inv_out.stderr is contains("this is stderr") if item.script_name is undefined else true - inv_out.stderr is regex(item.expected_error) + - item.deprecation is undefined or inv_out.stderr is regex(item.deprecation) diff -Nru ansible-core-2.19.1/test/integration/targets/inventory_script/tasks/test_valid_inventory.yml ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/test_valid_inventory.yml --- ansible-core-2.19.1/test/integration/targets/inventory_script/tasks/test_valid_inventory.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/test_valid_inventory.yml 2025-11-04 23:27:03.000000000 +0000 @@ -4,7 +4,6 @@ environment: ANSIBLE_INVENTORY_PLUGIN_SCRIPT_STDERR: '{{ item.show_stderr | default(omit) }}' ANSIBLE_DEPRECATION_WARNINGS: 1 # some tests assert deprecation warnings - INVENTORY_TEST_MODE: '{{ item.mode | default(omit) }}' INVENTORY_EMIT_STDERR: '{{ item.emit_stderr | default(omit) }}' register: inv_out diff -Nru ansible-core-2.19.1/test/integration/targets/lookup_config/tasks/main.yml ansible-core-2.19.4/test/integration/targets/lookup_config/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/lookup_config/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/lookup_config/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -83,14 +83,26 @@ ignore_errors: yes register: lookup_config_12 +- name: origins + set_fact: + config_origin1: "{{ lookup('config', '_Z_TEST_ENTRY', show_origin=True) }}" + ignore_errors: yes + +- name: var sets it + set_fact: + config_origin2: "{{ lookup('config', '_Z_TEST_ENTRY', show_origin=True) }}" + ignore_errors: yes + vars: + _z_test_entry: yolo + - name: Verify lookup_config assert: that: - '"meow" in lookup("config", "ANSIBLE_COW_ACCEPTLIST")' - lookup_config_1 is failed - - lookup_config_1.msg is contains "Setting 'THIS_DOES_NOT_EXIST' does not exist." + - lookup_config_1.msg is contains "No config definition exists for 'THIS_DOES_NOT_EXIST'" - lookup_config_2 is failed - - lookup_config_2.msg is contains "Setting 'THIS_DOES_NOT_EXIST' does not exist." + - lookup_config_2.msg is contains "No config definition exists for 'THIS_DOES_NOT_EXIST'" - lookup_config_3 is success - 'lookup3|length == 0' - lookup_config_4 is success @@ -100,7 +112,7 @@ - lookup_config_6 is failed - '"Invalid setting identifier" in lookup_config_6.msg' - lookup_config_7 is failed - - '"Invalid setting" in lookup_config_7.msg' + - lookup_config_7.msg is contains "No config definition exists for 'ConfigManager'" - lookup_config_8 is failed - '"Both plugin_type and plugin_name" in lookup_config_8.msg' - lookup_config_9 is failed @@ -114,3 +126,6 @@ - ssh_user_and_port == ['lola', 2022] - "ssh_user_and_port_and_origin == [['lola', 'var: ansible_ssh_user'], [2022, 'var: ansible_ssh_port']]" - yolo_remote == ["yolo"] + - config_origin1[1] == "default" + - config_origin2[0] == 'yolo' + - 'config_origin2[1] == "var: _z_test_entry"' diff -Nru ansible-core-2.19.1/test/integration/targets/signal_propagation/aliases ansible-core-2.19.4/test/integration/targets/signal_propagation/aliases --- ansible-core-2.19.1/test/integration/targets/signal_propagation/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/signal_propagation/aliases 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,3 @@ +shippable/posix/group4 +context/controller +needs/target/test_utils diff -Nru ansible-core-2.19.1/test/integration/targets/signal_propagation/inventory ansible-core-2.19.4/test/integration/targets/signal_propagation/inventory --- ansible-core-2.19.1/test/integration/targets/signal_propagation/inventory 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/signal_propagation/inventory 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,14 @@ +localhost0 +localhost1 +localhost2 +localhost3 +localhost4 +localhost5 +localhost6 +localhost7 +localhost8 +localhost9 + +[all:vars] +ansible_connection=local +ansible_python_interpreter={{ansible_playbook_python}} diff -Nru ansible-core-2.19.1/test/integration/targets/signal_propagation/runme.sh ansible-core-2.19.4/test/integration/targets/signal_propagation/runme.sh --- ansible-core-2.19.1/test/integration/targets/signal_propagation/runme.sh 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/signal_propagation/runme.sh 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -x + +../test_utils/scripts/timeout.py -s SIGINT 3 -- \ + ansible all -i inventory -m debug -a 'msg={{lookup("pipe", "sleep 33")}}' -f 10 +if [[ "$?" != "124" ]]; then + echo "Process was not terminated due to timeout" + exit 1 +fi + +# a short sleep to let processes die +sleep 2 + +sleeps="$(pgrep -alf 'sleep\ 33')" +rc="$?" +if [[ "$rc" == "0" ]]; then + echo "Found lingering processes:" + echo "$sleeps" + exit 1 +fi diff -Nru ansible-core-2.19.1/test/integration/targets/ssh_agent/tasks/tests.yml ansible-core-2.19.4/test/integration/targets/ssh_agent/tasks/tests.yml --- ansible-core-2.19.1/test/integration/targets/ssh_agent/tasks/tests.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/ssh_agent/tasks/tests.yml 2025-11-04 23:27:03.000000000 +0000 @@ -29,15 +29,17 @@ vars: pid: '{{ auto.stdout|regex_findall("ssh-agent\[(\d+)\]")|first }}' -- command: ssh-agent -D -s -a '{{ output_dir }}/agent.sock' - async: 30 - poll: 0 +- shell: ssh-agent -D -s -a '{{ output_dir }}/agent.sock' & + register: ssh_agent_result -- command: ansible-playbook -i {{ ansible_inventory_sources|first|quote }} -vvv {{ role_path }}/auto.yml - environment: - ANSIBLE_CALLBACK_RESULT_FORMAT: yaml - ANSIBLE_SSH_AGENT: '{{ output_dir }}/agent.sock' - register: existing +- block: + - command: ansible-playbook -i {{ ansible_inventory_sources|first|quote }} -vvv {{ role_path }}/auto.yml + environment: + ANSIBLE_CALLBACK_RESULT_FORMAT: yaml + ANSIBLE_SSH_AGENT: '{{ output_dir }}/agent.sock' + register: existing + always: + - command: "kill {{ ssh_agent_result.stdout | regex_search('Agent pid ([0-9]+)', '\\1') | first }}" - assert: that: diff -Nru ansible-core-2.19.1/test/integration/targets/test_utils/scripts/timeout.py ansible-core-2.19.4/test/integration/targets/test_utils/scripts/timeout.py --- ansible-core-2.19.1/test/integration/targets/test_utils/scripts/timeout.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/test_utils/scripts/timeout.py 2025-11-04 23:27:03.000000000 +0000 @@ -2,21 +2,32 @@ from __future__ import annotations import argparse +import signal import subprocess import sys + +def signal_type(v: str) -> signal.Signals: + if v.isdecimal(): + return signal.Signals(int(v)) + if not v.startswith('SIG'): + v = f'SIG{v}' + return getattr(signal.Signals, v) + + parser = argparse.ArgumentParser() parser.add_argument('duration', type=int) +parser.add_argument('--signal', '-s', default=signal.SIGTERM, type=signal_type) parser.add_argument('command', nargs='+') args = parser.parse_args() +p: subprocess.Popen | None = None try: - p = subprocess.run( - ' '.join(args.command), - shell=True, - timeout=args.duration, - check=False, - ) + p = subprocess.Popen(args.command) + p.wait(timeout=args.duration) sys.exit(p.returncode) except subprocess.TimeoutExpired: + if p and p.poll() is None: + p.send_signal(args.signal) + p.wait() sys.exit(124) diff -Nru ansible-core-2.19.1/test/integration/targets/win_async_wrapper/library/trailing_output.ps1 ansible-core-2.19.4/test/integration/targets/win_async_wrapper/library/trailing_output.ps1 --- ansible-core-2.19.1/test/integration/targets/win_async_wrapper/library/trailing_output.ps1 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/win_async_wrapper/library/trailing_output.ps1 2025-11-04 23:27:03.000000000 +0000 @@ -0,0 +1,6 @@ +#!powershell + +#AnsibleRequires -Wrapper + +[Console]::Out.WriteLine('{"changed": false, "test": 123}') +'trailing junk after module result' diff -Nru ansible-core-2.19.1/test/integration/targets/win_async_wrapper/tasks/main.yml ansible-core-2.19.4/test/integration/targets/win_async_wrapper/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/win_async_wrapper/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/win_async_wrapper/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -206,6 +206,21 @@ - not success_async_custom_dir_poll.failed - success_async_custom_dir_poll.results_file == win_output_dir + '\\' + async_custom_dir_poll.ansible_job_id +- name: test async with trailing output + trailing_output: + async: 10 + poll: 1 + register: async_trailing_output + +- name: assert test async with trailing output + assert: + that: + - async_trailing_output is not changed + - async_trailing_output.test == 123 + - async_trailing_output.warnings | count == 1 + - >- + async_trailing_output.warnings[0] is search('Module invocation had junk after the JSON data: trailing junk after module result') + # FUTURE: figure out why the last iteration of this test often fails on shippable #- name: loop async success # async_test: diff -Nru ansible-core-2.19.1/test/integration/targets/win_exec_wrapper/tasks/main.yml ansible-core-2.19.4/test/integration/targets/win_exec_wrapper/tasks/main.yml --- ansible-core-2.19.1/test/integration/targets/win_exec_wrapper/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/integration/targets/win_exec_wrapper/tasks/main.yml 2025-11-04 23:27:03.000000000 +0000 @@ -268,14 +268,6 @@ <<: *become_vars ansible_remote_tmp: C:\Windows\TEMP\test-dir - - name: assert warning about tmpdir deletion is present - assert: - that: - - temp_deletion_warning.warnings | count == 1 - - >- - temp_deletion_warning.warnings[0] is - regex("(?i).*Failed to cleanup temporary directory 'C:\\\\Windows\\\\TEMP\\\\test-dir\\\\.*' used for compiling C# code\\. Files may still be present after the task is complete\\..*") - always: - name: ensure test user is deleted win_user: diff -Nru ansible-core-2.19.1/test/lib/ansible_test/_internal/ci/__init__.py ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/__init__.py --- ansible-core-2.19.1/test/lib/ansible_test/_internal/ci/__init__.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/__init__.py 2025-11-04 23:27:03.000000000 +0000 @@ -3,22 +3,13 @@ from __future__ import annotations import abc -import base64 +import dataclasses +import datetime import json -import os +import pathlib import tempfile import typing as t -from ..encoding import ( - to_bytes, - to_text, -) - -from ..io import ( - read_text_file, - write_text_file, -) - from ..config import ( CommonConfig, TestConfig, @@ -34,6 +25,65 @@ ) +@dataclasses.dataclass(frozen=True, kw_only=True) +class AuthContext: + """Information about the request to which authentication will be applied.""" + + stage: str + provider: str + request_id: str + + +class AuthHelper: + """Authentication helper.""" + + NAMESPACE: t.ClassVar = 'ci@core.ansible.com' + + def __init__(self, key_file: pathlib.Path) -> None: + self.private_key_file = pathlib.Path(str(key_file).removesuffix('.pub')) + self.public_key_file = pathlib.Path(f'{self.private_key_file}.pub') + + def sign_request(self, request: dict[str, object], context: AuthContext) -> None: + """Sign the given auth request using the provided context.""" + request.update( + stage=context.stage, + provider=context.provider, + request_id=context.request_id, + timestamp=datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat(), + ) + + with tempfile.TemporaryDirectory() as temp_dir: + payload_path = pathlib.Path(temp_dir) / 'auth.json' + payload_path.write_text(json.dumps(request, sort_keys=True)) + + cmd = ['ssh-keygen', '-q', '-Y', 'sign', '-f', str(self.private_key_file), '-n', self.NAMESPACE, str(payload_path)] + raw_command(cmd, capture=False, interactive=True) + + signature_path = pathlib.Path(f'{payload_path}.sig') + signature = signature_path.read_text() + + request.update(signature=signature) + + +class GeneratingAuthHelper(AuthHelper, metaclass=abc.ABCMeta): + """Authentication helper which generates a key pair on demand.""" + + def __init__(self) -> None: + super().__init__(pathlib.Path('~/.ansible/test/ansible-core-ci').expanduser()) + + def sign_request(self, request: dict[str, object], context: AuthContext) -> None: + if not self.private_key_file.exists(): + self.generate_key_pair() + + super().sign_request(request, context) + + def generate_key_pair(self) -> None: + """Generate key pair.""" + self.private_key_file.parent.mkdir(parents=True, exist_ok=True) + + raw_command(['ssh-keygen', '-q', '-f', str(self.private_key_file), '-N', ''], capture=True) + + class ChangeDetectionNotSupported(ApplicationError): """Exception for cases where change detection is not supported.""" @@ -75,8 +125,8 @@ """Return True if Ansible Core CI is supported.""" @abc.abstractmethod - def prepare_core_ci_auth(self) -> dict[str, t.Any]: - """Return authentication details for Ansible Core CI.""" + def prepare_core_ci_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]: + """Prepare an Ansible Core CI request using the given config and context.""" @abc.abstractmethod def get_git_details(self, args: CommonConfig) -> t.Optional[dict[str, t.Any]]: @@ -101,119 +151,3 @@ display.info('Detected CI provider: %s' % provider.name) return provider - - -class AuthHelper(metaclass=abc.ABCMeta): - """Public key based authentication helper for Ansible Core CI.""" - - def sign_request(self, request: dict[str, t.Any]) -> None: - """Sign the given auth request and make the public key available.""" - payload_bytes = to_bytes(json.dumps(request, sort_keys=True)) - signature_raw_bytes = self.sign_bytes(payload_bytes) - signature = to_text(base64.b64encode(signature_raw_bytes)) - - request.update(signature=signature) - - def initialize_private_key(self) -> str: - """ - Initialize and publish a new key pair (if needed) and return the private key. - The private key is cached across ansible-test invocations, so it is only generated and published once per CI job. - """ - path = os.path.expanduser('~/.ansible-core-ci-private.key') - - if os.path.exists(to_bytes(path)): - private_key_pem = read_text_file(path) - else: - private_key_pem = self.generate_private_key() - write_text_file(path, private_key_pem) - - return private_key_pem - - @abc.abstractmethod - def sign_bytes(self, payload_bytes: bytes) -> bytes: - """Sign the given payload and return the signature, initializing a new key pair if required.""" - - @abc.abstractmethod - def publish_public_key(self, public_key_pem: str) -> None: - """Publish the given public key.""" - - @abc.abstractmethod - def generate_private_key(self) -> str: - """Generate a new key pair, publishing the public key and returning the private key.""" - - -class CryptographyAuthHelper(AuthHelper, metaclass=abc.ABCMeta): - """Cryptography based public key based authentication helper for Ansible Core CI.""" - - def sign_bytes(self, payload_bytes: bytes) -> bytes: - """Sign the given payload and return the signature, initializing a new key pair if required.""" - # import cryptography here to avoid overhead and failures in environments which do not use/provide it - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import ec - from cryptography.hazmat.primitives.serialization import load_pem_private_key - - private_key_pem = self.initialize_private_key() - private_key = load_pem_private_key(to_bytes(private_key_pem), None, default_backend()) - - assert isinstance(private_key, ec.EllipticCurvePrivateKey) - - signature_raw_bytes = private_key.sign(payload_bytes, ec.ECDSA(hashes.SHA256())) - - return signature_raw_bytes - - def generate_private_key(self) -> str: - """Generate a new key pair, publishing the public key and returning the private key.""" - # import cryptography here to avoid overhead and failures in environments which do not use/provide it - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric import ec - - private_key = ec.generate_private_key(ec.SECP384R1(), default_backend()) - public_key = private_key.public_key() - - private_key_pem = to_text(private_key.private_bytes( # type: ignore[attr-defined] # documented method, but missing from type stubs - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - )) - - public_key_pem = to_text(public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - )) - - self.publish_public_key(public_key_pem) - - return private_key_pem - - -class OpenSSLAuthHelper(AuthHelper, metaclass=abc.ABCMeta): - """OpenSSL based public key based authentication helper for Ansible Core CI.""" - - def sign_bytes(self, payload_bytes: bytes) -> bytes: - """Sign the given payload and return the signature, initializing a new key pair if required.""" - private_key_pem = self.initialize_private_key() - - with tempfile.NamedTemporaryFile() as private_key_file: - private_key_file.write(to_bytes(private_key_pem)) - private_key_file.flush() - - with tempfile.NamedTemporaryFile() as payload_file: - payload_file.write(payload_bytes) - payload_file.flush() - - with tempfile.NamedTemporaryFile() as signature_file: - raw_command(['openssl', 'dgst', '-sha256', '-sign', private_key_file.name, '-out', signature_file.name, payload_file.name], capture=True) - signature_raw_bytes = signature_file.read() - - return signature_raw_bytes - - def generate_private_key(self) -> str: - """Generate a new key pair, publishing the public key and returning the private key.""" - private_key_pem = raw_command(['openssl', 'ecparam', '-genkey', '-name', 'secp384r1', '-noout'], capture=True)[0] - public_key_pem = raw_command(['openssl', 'ec', '-pubout'], data=private_key_pem, capture=True)[0] - - self.publish_public_key(public_key_pem) - - return private_key_pem diff -Nru ansible-core-2.19.1/test/lib/ansible_test/_internal/ci/azp.py ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/azp.py --- ansible-core-2.19.1/test/lib/ansible_test/_internal/ci/azp.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/azp.py 2025-11-04 23:27:03.000000000 +0000 @@ -31,9 +31,10 @@ ) from . import ( + AuthContext, ChangeDetectionNotSupported, CIProvider, - CryptographyAuthHelper, + GeneratingAuthHelper, ) CODE = 'azp' @@ -112,10 +113,11 @@ """Return True if Ansible Core CI is supported.""" return True - def prepare_core_ci_auth(self) -> dict[str, t.Any]: - """Return authentication details for Ansible Core CI.""" + def prepare_core_ci_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]: try: - request = dict( + request: dict[str, object] = dict( + type="azp:ssh", + config=config, org_name=os.environ['SYSTEM_COLLECTIONURI'].strip('/').split('/')[-1], project_name=os.environ['SYSTEM_TEAMPROJECT'], build_id=int(os.environ['BUILD_BUILDID']), @@ -124,13 +126,9 @@ except KeyError as ex: raise MissingEnvironmentVariable(name=ex.args[0]) from None - self.auth.sign_request(request) + self.auth.sign_request(request, context) - auth = dict( - azp=request, - ) - - return auth + return request def get_git_details(self, args: CommonConfig) -> t.Optional[dict[str, t.Any]]: """Return details about git in the current environment.""" @@ -144,14 +142,14 @@ return details -class AzurePipelinesAuthHelper(CryptographyAuthHelper): - """ - Authentication helper for Azure Pipelines. - Based on cryptography since it is provided by the default Azure Pipelines environment. - """ +class AzurePipelinesAuthHelper(GeneratingAuthHelper): + """Authentication helper for Azure Pipelines.""" + + def generate_key_pair(self) -> None: + super().generate_key_pair() + + public_key_pem = self.public_key_file.read_text() - def publish_public_key(self, public_key_pem: str) -> None: - """Publish the given public key.""" try: agent_temp_directory = os.environ['AGENT_TEMPDIRECTORY'] except KeyError as ex: diff -Nru ansible-core-2.19.1/test/lib/ansible_test/_internal/ci/local.py ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/local.py --- ansible-core-2.19.1/test/lib/ansible_test/_internal/ci/local.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/local.py 2025-11-04 23:27:03.000000000 +0000 @@ -2,10 +2,12 @@ from __future__ import annotations -import os +import abc +import inspect import platform import random import re +import pathlib import typing as t from ..config import ( @@ -24,11 +26,14 @@ from ..util import ( ApplicationError, display, + get_subclasses, is_binary_file, SubprocessError, ) from . import ( + AuthContext, + AuthHelper, CIProvider, ) @@ -120,34 +125,20 @@ def supports_core_ci_auth(self) -> bool: """Return True if Ansible Core CI is supported.""" - path = self._get_aci_key_path() - return os.path.exists(path) + return Authenticator.available() - def prepare_core_ci_auth(self) -> dict[str, t.Any]: - """Return authentication details for Ansible Core CI.""" - path = self._get_aci_key_path() - auth_key = read_text_file(path).strip() + def prepare_core_ci_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]: + if not (authenticator := Authenticator.load()): + raise ApplicationError('Ansible Core CI authentication has not been configured.') - request = dict( - key=auth_key, - nonce=None, - ) - - auth = dict( - remote=request, - ) + display.info(f'Using {authenticator} for Ansible Core CI.', verbosity=1) - return auth + return authenticator.prepare_auth_request(config, context) def get_git_details(self, args: CommonConfig) -> t.Optional[dict[str, t.Any]]: """Return details about git in the current environment.""" return None # not yet implemented for local - @staticmethod - def _get_aci_key_path() -> str: - path = os.path.expanduser('~/.ansible-core-ci.key') - return path - class InvalidBranch(ApplicationError): """Exception for invalid branch specification.""" @@ -214,3 +205,108 @@ return True return False + + +class Authenticator(metaclass=abc.ABCMeta): + """Base class for authenticators.""" + + @staticmethod + def list() -> list[type[Authenticator]]: + """List all authenticators in priority order.""" + return sorted((sc for sc in get_subclasses(Authenticator) if not inspect.isabstract(sc)), key=lambda obj: obj.priority()) + + @staticmethod + def load() -> Authenticator | None: + """Load an authenticator instance, returning None if not configured.""" + for implementation in Authenticator.list(): + if implementation.config_file().exists(): + return implementation() + + return None + + @staticmethod + def available() -> bool: + """Return True if an authenticator is available, otherwise False.""" + return bool(Authenticator.load()) + + @classmethod + @abc.abstractmethod + def priority(cls) -> int: + """Priority used to determine which authenticator is tried first, from lowest to highest.""" + + @classmethod + @abc.abstractmethod + def config_file(cls) -> pathlib.Path: + """Path to the config file for this authenticator.""" + + @abc.abstractmethod + def prepare_auth_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]: + """Prepare an authenticated Ansible Core CI request using the given config and context.""" + + def __str__(self) -> str: + return self.__class__.__name__ + + +class PasswordAuthenticator(Authenticator): + """Authenticate using a password.""" + + @classmethod + def priority(cls) -> int: + return 200 + + @classmethod + def config_file(cls) -> pathlib.Path: + return pathlib.Path('~/.ansible-core-ci.key').expanduser() + + def prepare_auth_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]: + parts = self.config_file().read_text().strip().split(maxsplit=1) + + if len(parts) == 1: # temporary backward compatibility for legacy API keys + request = dict( + config=config, + auth=dict( + remote=dict( + key=parts[0], + ), + ), + ) + + return request + + username, password = parts + + request = dict( + type="remote:password", + config=config, + username=username, + password=password, + ) + + return request + + +class SshAuthenticator(Authenticator): + """Authenticate using an SSH key.""" + + @classmethod + def priority(cls) -> int: + return 100 + + @classmethod + def config_file(cls) -> pathlib.Path: + return pathlib.Path('~/.ansible-core-ci.auth').expanduser() + + def prepare_auth_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]: + parts = self.config_file().read_text().strip().split(maxsplit=1) + username, key_file = parts + + request: dict[str, object] = dict( + type="remote:ssh", + config=config, + username=username, + ) + + auth_helper = AuthHelper(pathlib.Path(key_file).expanduser()) + auth_helper.sign_request(request, context) + + return request diff -Nru ansible-core-2.19.1/test/lib/ansible_test/_internal/core_ci.py ansible-core-2.19.4/test/lib/ansible_test/_internal/core_ci.py --- ansible-core-2.19.1/test/lib/ansible_test/_internal/core_ci.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/lib/ansible_test/_internal/core_ci.py 2025-11-04 23:27:03.000000000 +0000 @@ -42,6 +42,7 @@ ) from .ci import ( + AuthContext, get_ci_provider, ) @@ -68,6 +69,10 @@ def persist(self) -> bool: """True if the resource is persistent, otherwise false.""" + @abc.abstractmethod + def get_config(self, core_ci: AnsibleCoreCI) -> dict[str, object]: + """Return the configuration for this resource.""" + @dataclasses.dataclass(frozen=True) class VmResource(Resource): @@ -92,6 +97,16 @@ """True if the resource is persistent, otherwise false.""" return True + def get_config(self, core_ci: AnsibleCoreCI) -> dict[str, object]: + """Return the configuration for this resource.""" + return dict( + type="vm", + platform=self.platform, + version=self.version, + architecture=self.architecture, + public_key=core_ci.ssh_key.pub_contents, + ) + @dataclasses.dataclass(frozen=True) class CloudResource(Resource): @@ -112,6 +127,12 @@ """True if the resource is persistent, otherwise false.""" return False + def get_config(self, core_ci: AnsibleCoreCI) -> dict[str, object]: + """Return the configuration for this resource.""" + return dict( + type="cloud", + ) + class AnsibleCoreCI: """Client for Ansible Core CI services.""" @@ -189,7 +210,7 @@ display.info(f'Skipping started {self.label} instance.', verbosity=1) return None - return self._start(self.ci_provider.prepare_core_ci_auth()) + return self._start() def stop(self) -> None: """Stop instance.""" @@ -288,26 +309,25 @@ def _uri(self) -> str: return f'{self.endpoint}/{self.stage}/{self.provider}/{self.instance_id}' - def _start(self, auth) -> dict[str, t.Any]: + def _start(self) -> dict[str, t.Any]: """Start instance.""" display.info(f'Initializing new {self.label} instance using: {self._uri}', verbosity=1) - data = dict( - config=dict( - platform=self.platform, - version=self.version, - architecture=self.arch, - public_key=self.ssh_key.pub_contents, - ) + config = self.resource.get_config(self) + + context = AuthContext( + request_id=self.instance_id, + stage=self.stage, + provider=self.provider, ) - data.update(auth=auth) + request = self.ci_provider.prepare_core_ci_request(config, context) headers = { 'Content-Type': 'application/json', } - response = self._start_endpoint(data, headers) + response = self._start_endpoint(request, headers) self.started = True self._save() diff -Nru ansible-core-2.19.1/test/lib/ansible_test/_internal/host_profiles.py ansible-core-2.19.4/test/lib/ansible_test/_internal/host_profiles.py --- ansible-core-2.19.1/test/lib/ansible_test/_internal/host_profiles.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/lib/ansible_test/_internal/host_profiles.py 2025-11-04 23:27:03.000000000 +0000 @@ -265,6 +265,9 @@ def name(self) -> str: """The name of the host profile.""" + def pre_provision(self) -> None: + """Pre-provision the host profile.""" + def provision(self) -> None: """Provision the host before delegation.""" @@ -522,8 +525,8 @@ """The saved Ansible Core CI state.""" self.state['core_ci'] = value - def provision(self) -> None: - """Provision the host before delegation.""" + def pre_provision(self) -> None: + """Pre-provision the host before delegation.""" self.core_ci = self.create_core_ci(load=True) self.core_ci.start() diff -Nru ansible-core-2.19.1/test/lib/ansible_test/_internal/provisioning.py ansible-core-2.19.4/test/lib/ansible_test/_internal/provisioning.py --- ansible-core-2.19.1/test/lib/ansible_test/_internal/provisioning.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/lib/ansible_test/_internal/provisioning.py 2025-11-04 23:27:03.000000000 +0000 @@ -132,6 +132,9 @@ ExitHandler.register(functools.partial(cleanup_profiles, host_state)) + for pre_profile in host_state.profiles: + pre_profile.pre_provision() + def provision(profile: HostProfile) -> None: """Provision the given profile.""" profile.provision() diff -Nru ansible-core-2.19.1/test/lib/ansible_test/_internal/util.py ansible-core-2.19.4/test/lib/ansible_test/_internal/util.py --- ansible-core-2.19.1/test/lib/ansible_test/_internal/util.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/lib/ansible_test/_internal/util.py 2025-11-04 23:27:03.000000000 +0000 @@ -707,6 +707,7 @@ optional = ( 'LD_LIBRARY_PATH', 'SSH_AUTH_SOCK', + 'SSH_SK_PROVIDER', # MacOS High Sierra Compatibility # http://sealiesoftware.com/blog/archive/2017/6/5/Objective-C_and_fork_in_macOS_1013.html # Example configuration for macOS: diff -Nru ansible-core-2.19.1/test/units/_internal/templating/test_jinja_bits.py ansible-core-2.19.4/test/units/_internal/templating/test_jinja_bits.py --- ansible-core-2.19.1/test/units/_internal/templating/test_jinja_bits.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/_internal/templating/test_jinja_bits.py 2025-11-04 23:27:03.000000000 +0000 @@ -9,6 +9,7 @@ import pytest import pytest_mock +from ansible._internal import _display_utils from ansible._internal._templating._access import NotifiableAccessContextBase from ansible.errors import AnsibleUndefinedVariable, AnsibleTemplateError from ansible._internal._templating._errors import AnsibleTemplatePluginRuntimeError @@ -22,8 +23,6 @@ from ansible._internal._templating._engine import TemplateEngine, TemplateOptions from jinja2.loaders import DictLoader -from ansible.utils.display import _DeferredWarningContext - if t.TYPE_CHECKING: import unittest.mock @@ -79,7 +78,7 @@ templar = TemplateEngine() templar.environment.loader = DictLoader(dict(foo=TRUST.tag('{{ undefined_in_import }}'))) - with _DeferredWarningContext(variables=templar.available_variables) as warnings: + with _display_utils.DeferredWarningContext(variables=templar.available_variables) as warnings: result = templar.template(template) assert not warnings.get_warnings() diff -Nru ansible-core-2.19.1/test/units/_internal/templating/test_templar.py ansible-core-2.19.4/test/units/_internal/templating/test_templar.py --- ansible-core-2.19.1/test/units/_internal/templating/test_templar.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/_internal/templating/test_templar.py 2025-11-04 23:27:03.000000000 +0000 @@ -31,6 +31,7 @@ import unittest +from ansible._internal import _display_utils from ansible._internal._templating._datatag import _JinjaConstTemplate from ansible.errors import ( AnsibleError, AnsibleUndefinedVariable, AnsibleTemplateSyntaxError, AnsibleBrokenConditionalError, AnsibleTemplateError, AnsibleTemplateTransformLimitError, @@ -52,7 +53,7 @@ from ansible._internal._templating._marker_behaviors import ReplacingMarkerBehavior from ansible._internal._templating._utils import TemplateContext from ansible.module_utils._internal import _event_utils -from ansible.utils.display import Display, _DeferredWarningContext +from ansible.utils.display import Display from units.mock.loader import DictDataLoader from units.test_utils.controller.display import emits_warnings @@ -1033,7 +1034,7 @@ templar = TemplateEngine(variables=variables) - with _DeferredWarningContext(variables=variables) as dwc: + with _display_utils.DeferredWarningContext(variables=variables) as dwc: # The indirect access summary occurs first. # The two following direct access summaries get deduped to a single one by the warning context (but unique template value keeps distinct from indirect). # The accesses with the shared tag instance values are internally deduped by the audit context. @@ -1049,14 +1050,14 @@ def test_jinja_const_template_leak(template_context: TemplateContext) -> None: """Verify that _JinjaConstTemplate is present during internal templating.""" - with _DeferredWarningContext(variables={}): # suppress warning from usage of embedded template + with _display_utils.DeferredWarningContext(variables={}): # suppress warning from usage of embedded template with unittest.mock.patch.object(_TemplateConfig, 'allow_embedded_templates', True): assert _JinjaConstTemplate.is_tagged_on(TemplateEngine().template(TRUST.tag("{{ '{{ 1 }}' }}"))) def test_jinja_const_template_finalized() -> None: """Verify that _JinjaConstTemplate is not present in finalized template results.""" - with _DeferredWarningContext(variables={}): # suppress warning from usage of embedded template + with _display_utils.DeferredWarningContext(variables={}): # suppress warning from usage of embedded template with unittest.mock.patch.object(_TemplateConfig, 'allow_embedded_templates', True): assert not _JinjaConstTemplate.is_tagged_on(TemplateEngine().template(TRUST.tag("{{ '{{ 1 }}' }}"))) diff -Nru ansible-core-2.19.1/test/units/ansible_test/ci/test_azp.py ansible-core-2.19.4/test/units/ansible_test/ci/test_azp.py --- ansible-core-2.19.1/test/units/ansible_test/ci/test_azp.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/ansible_test/ci/test_azp.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,30 +0,0 @@ -from __future__ import annotations - -from .util import common_auth_test - - -def test_auth(): - # noinspection PyProtectedMember - from ansible_test._internal.ci.azp import ( - AzurePipelinesAuthHelper, - ) - - class TestAzurePipelinesAuthHelper(AzurePipelinesAuthHelper): - def __init__(self): - self.public_key_pem = None - self.private_key_pem = None - - def publish_public_key(self, public_key_pem): - # avoid publishing key - self.public_key_pem = public_key_pem - - def initialize_private_key(self): - # cache in memory instead of on disk - if not self.private_key_pem: - self.private_key_pem = self.generate_private_key() - - return self.private_key_pem - - auth = TestAzurePipelinesAuthHelper() - - common_auth_test(auth) diff -Nru ansible-core-2.19.1/test/units/ansible_test/ci/util.py ansible-core-2.19.4/test/units/ansible_test/ci/util.py --- ansible-core-2.19.1/test/units/ansible_test/ci/util.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/ansible_test/ci/util.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,50 +0,0 @@ -from __future__ import annotations - -import base64 -import json -import re - - -def common_auth_test(auth): - private_key_pem = auth.initialize_private_key() - public_key_pem = auth.public_key_pem - - extract_pem_key(private_key_pem, private=True) - extract_pem_key(public_key_pem, private=False) - - request = dict(hello='World') - auth.sign_request(request) - - verify_signature(request, public_key_pem) - - -def extract_pem_key(value, private): - assert isinstance(value, type(u'')) - - key_type = '(EC )?PRIVATE' if private else 'PUBLIC' - pattern = r'^-----BEGIN ' + key_type + r' KEY-----\n(?P.*?)\n-----END ' + key_type + r' KEY-----\n$' - match = re.search(pattern, value, flags=re.DOTALL) - - assert match, 'key "%s" does not match pattern "%s"' % (value, pattern) - - base64.b64decode(match.group('key')) # make sure the key can be decoded - - -def verify_signature(request, public_key_pem): - signature = request.pop('signature') - payload_bytes = json.dumps(request, sort_keys=True).encode() - - assert isinstance(signature, type(u'')) - - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import ec - from cryptography.hazmat.primitives.serialization import load_pem_public_key - - public_key = load_pem_public_key(public_key_pem.encode(), default_backend()) - - public_key.verify( - base64.b64decode(signature.encode()), - payload_bytes, - ec.ECDSA(hashes.SHA256()), - ) diff -Nru ansible-core-2.19.1/test/units/errors/test_utils.py ansible-core-2.19.4/test/units/errors/test_utils.py --- ansible-core-2.19.1/test/units/errors/test_utils.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/errors/test_utils.py 2025-11-04 23:27:03.000000000 +0000 @@ -5,9 +5,9 @@ from ansible._internal._errors import _error_factory from ansible.errors import AnsibleError +from ansible._internal import _display_utils from ansible._internal._datatag._tags import Origin from ansible._internal._errors._error_utils import format_exception_message -from ansible.utils.display import _format_message from ansible.module_utils._internal import _messages from units.mock.error_helper import raise_exceptions @@ -186,7 +186,7 @@ event = _error_factory.ControllerEventFactory.from_exception(error.value, False) message_chain = format_exception_message(error.value) - formatted_message = _format_message(_messages.ErrorSummary(event=event), False) + formatted_message = _display_utils.format_message(_messages.ErrorSummary(event=event), False) assert message_chain == expected_message_chain assert formatted_message.strip() == (expected_formatted_message or expected_message_chain) diff -Nru ansible-core-2.19.1/test/units/parsing/yaml/test_objects.py ansible-core-2.19.4/test/units/parsing/yaml/test_objects.py --- ansible-core-2.19.1/test/units/parsing/yaml/test_objects.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/parsing/yaml/test_objects.py 2025-11-04 23:27:03.000000000 +0000 @@ -4,17 +4,17 @@ import pytest +from ansible._internal import _display_utils from ansible._internal._datatag._tags import Origin from ansible.module_utils._internal._datatag import AnsibleTagHelper from ansible.parsing.vault import EncryptedString from ansible.parsing.yaml.objects import _AnsibleMapping, _AnsibleUnicode, _AnsibleSequence -from ansible.utils.display import _DeferredWarningContext from ansible.parsing.yaml import objects @pytest.fixture(autouse=True, scope='function') def suppress_warnings() -> t.Generator[None]: - with _DeferredWarningContext(variables={}): + with _display_utils.DeferredWarningContext(variables={}): yield diff -Nru ansible-core-2.19.1/test/units/test_utils/controller/display.py ansible-core-2.19.4/test/units/test_utils/controller/display.py --- ansible-core-2.19.1/test/units/test_utils/controller/display.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/test_utils/controller/display.py 2025-11-04 23:27:03.000000000 +0000 @@ -4,8 +4,8 @@ import re import typing as t +from ansible._internal import _display_utils from ansible.module_utils._internal import _messages -from ansible.utils.display import _DeferredWarningContext @contextlib.contextmanager @@ -16,7 +16,7 @@ allow_unmatched_message: bool = False, ) -> t.Iterator[None]: """Assert that the code within the context manager body emits a warning or deprecation warning whose formatted output matches the supplied regex.""" - with _DeferredWarningContext(variables=dict(ansible_deprecation_warnings=True)) as ctx: + with _display_utils.DeferredWarningContext(variables=dict(ansible_deprecation_warnings=True)) as ctx: yield deprecations = ctx.get_deprecation_warnings() diff -Nru ansible-core-2.19.1/test/units/utils/test_display.py ansible-core-2.19.4/test/units/utils/test_display.py --- ansible-core-2.19.1/test/units/utils/test_display.py 2025-08-25 19:16:05.000000000 +0000 +++ ansible-core-2.19.4/test/units/utils/test_display.py 2025-11-04 23:27:03.000000000 +0000 @@ -16,8 +16,9 @@ from ansible.module_utils.datatag import deprecator_from_collection_name from ansible.module_utils._internal import _deprecator, _errors, _messages -from ansible.utils.display import _LIBC, _MAX_INT, Display, get_text_width, _format_message +from ansible.utils.display import _LIBC, _MAX_INT, Display, get_text_width from ansible.utils.multiprocessing import context as multiprocessing_context +from ansible._internal import _display_utils @pytest.fixture @@ -164,7 +165,7 @@ ), ) - result = _format_message(_messages.DeprecationSummary(event=event), False) + result = _display_utils.format_message(_messages.DeprecationSummary(event=event), False) assert result == '''Ignoring ExceptionX. This feature will be removed in the future: Something went wrong. @@ -236,7 +237,7 @@ for kwarg in ('version', 'date', 'deprecator'): kwargs.setdefault(kwarg, None) - msg = Display()._get_deprecation_message_with_plugin_info(**kwargs) + msg = _display_utils.get_deprecation_message_with_plugin_info(**kwargs) assert msg == expected