--- Begin Message ---
Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: ansible-core@packages.debian.org, debian@rocketjump.eu
Control: affects -1 + src:ansible-core
User: release.debian.org@packages.debian.org
Usertags: pu
[ Reason ]
This is a bugfix only update of ansible-core in the 2.19.x series. Notably, this
fixes #1114932, which was reported by Helmut Grohne.
It also fixes sporadic CI test failures when the python3 package could be
upgraded but was held back due to pinning (e.g. the version in testing/unstable
was different, or in trixie/trixie-proposed-updates). This issue was debugged
and fixed by Colin Watson.
[ Impact ]
If not approved, users will have a slightly more buggy 2.19.1, and Helmut will
have to manually carry the patch. The old package might also create false
reverse dep CI failures for python3 going to trixie-proposed-updates.
[ Tests ]
Upstream has stellar CI tests, they add new tests for every bug they fix. The
tests also pass when running against Debian. On top of that, I manually tested
my playbooks against my servers to check for any regressions.
[ Risks ]
The changes are small and targeted.
[ Checklist ]
[x] *all* changes are documented in the d/changelog
[x] I reviewed all changes and I approve them
[x] attach debdiff against the package in (old)stable
[x] the issue is verified as fixed in unstable
[ Changes ]
The upstream changes are all documented here:
https://github.com/ansible/ansible/blob/stable-2.19/changelogs/CHANGELOG-v2.19.rst#v2-19-4
Every change in the code has a changelog entry.
On top of that I have included two smaller fixes to autopkgtest logging, and
also the fix by Colin Watson for the hard-to-reproduce CI test failure.
[ Other info ]
I have included a debdiff against the version currently in t-p-u instead, I
think that makes more sense.
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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/ansible_core.egg-info/PKG-INFO 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/ansible_core.egg-info/SOURCES.txt 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/changelogs/CHANGELOG-v2.19.rst 2025-11-05 00:27:03.000000000 +0100
@@ -4,6 +4,73 @@
.. contents:: Topics
+v2.19.4
+=======
+
+Release Summary
+---------------
+
+| Release Date: 2025-11-04
+| `Porting Guide <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/changelogs/changelog.yaml 2025-11-05 00:27:03.000000000 +0100
@@ -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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+ '
+ 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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+ '
+ 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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+ '
+ 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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+ '
+ 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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+ '
+ 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 <https://docs.ansible.com/ansible-core/2.19/porting_guides/porting_guide_core_2.19.html>`__
+
+ '
+ 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 22:44:31.000000000 +0200
+++ ansible-core-2.19.4/debian/changelog 2025-11-07 23:26:04.000000000 +0100
@@ -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 <debian@rocketjump.eu> 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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/debian/patches/integration-test-apt-sources-list.patch 2025-11-05 19:29:02.000000000 +0100
@@ -0,0 +1,114 @@
+From: Colin Watson <cjwatson@debian.org>
+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 12:45:56.000000000 +0200
+++ ansible-core-2.19.4/debian/patches/series 2025-11-05 19:29:50.000000000 +0100
@@ -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 22:34:56.000000000 +0200
+++ ansible-core-2.19.4/debian/tests/ansible-test-integration.py 2025-11-05 19:28:39.000000000 +0100
@@ -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 12:45:56.000000000 +0200
+++ ansible-core-2.19.4/debian/tests/testbed-setup.sh 2025-11-05 19:36:59.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/cli/arguments/option_helpers.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/cli/doc.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/collections/list.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/config/base.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/executor/play_iterator.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/executor/powershell/async_watchdog.ps1 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/executor/process/worker.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/executor/task_executor.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/executor/task_queue_manager.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/_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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/lib/ansible/_internal/_display_utils.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/modules/package_facts.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/module_utils/ansible_release.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/module_utils/basic.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/module_utils/urls.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/parsing/mod_args.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/playbook/block.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/playbook/helpers.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/playbook/play.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/playbook/task.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/plugins/connection/psrp.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/plugins/filter/core.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/plugins/inventory/script.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/plugins/list.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/plugins/loader.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/plugins/lookup/config.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/plugins/strategy/__init__.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/release.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/lib/ansible/utils/display.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/PKG-INFO ansible-core-2.19.4/PKG-INFO
--- ansible-core-2.19.1/PKG-INFO 2025-08-25 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/PKG-INFO 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/in_subdir.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/filter_subdir/nested.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/module_utils/__init__.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/ansible-doc/runme.sh 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/callback_plugins/display_resolved_action.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/dynamic_action.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin/unqualified.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/collections/test_task_resolved_plugin.sh 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/connection_ssh/aliases 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/connection_ssh/runme.sh 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/deprecations/runme.sh 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/filter_core/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/get_url/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/handlers/rescue_flush_handlers.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/handlers/runme.sh 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/handlers/tagged_play.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/bar.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/foo.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/include_import/roles/nested_tasks/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/include_import/runme.sh 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/include_import/test_nested_non_existent_tasks.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/include_import_tasks_nested/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/include_import_tasks_nested/tests/tests_relative.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/inventory_script/bad_types 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/test_broken_inventory.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/inventory_script/tasks/test_valid_inventory.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/lookup_config/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/signal_propagation/aliases 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/signal_propagation/inventory 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/signal_propagation/runme.sh 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/ssh_agent/tasks/tests.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/test_utils/scripts/timeout.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 01:00:00.000000000 +0100
+++ ansible-core-2.19.4/test/integration/targets/win_async_wrapper/library/trailing_output.ps1 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/win_async_wrapper/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/integration/targets/win_exec_wrapper/tasks/main.yml 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/azp.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/__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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/__init__.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/lib/ansible_test/_internal/ci/local.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/lib/ansible_test/_internal/core_ci.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/lib/ansible_test/_internal/host_profiles.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/lib/ansible_test/_internal/provisioning.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/lib/ansible_test/_internal/util.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/ansible_test/ci/test_azp.py 1970-01-01 01:00:00.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/ansible_test/ci/util.py 1970-01-01 01:00:00.000000000 +0100
@@ -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<key>.*?)\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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/errors/test_utils.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/_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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/_internal/templating/test_jinja_bits.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/_internal/templating/test_templar.py 2025-11-05 00:27:03.000000000 +0100
@@ -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/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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/parsing/yaml/test_objects.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/test_utils/controller/display.py 2025-11-05 00:27:03.000000000 +0100
@@ -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 21:16:05.000000000 +0200
+++ ansible-core-2.19.4/test/units/utils/test_display.py 2025-11-05 00:27:03.000000000 +0100
@@ -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
--- End Message ---