From 31b3cc35e6b35fdb80feecd1d4b3422f400fa07a Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Fri, 5 Jun 2026 10:15:42 -0700 Subject: [PATCH] Drop Python 3.12 controller support (#87057) * Drop Python 3.12 controller support * Remove obsolete code * Remove obsolete centos6 test code * Skip tests on unsupported platforms * Work around lack of Alpine controller support in tests --- .azure-pipelines/azure-pipelines.yml | 18 ++----- changelogs/fragments/python-support.yml | 1 + lib/ansible/cli/__init__.py | 2 +- lib/ansible/cli/_ssh_askpass.py | 12 +---- lib/ansible/config/base.yml | 1 + lib/ansible/plugins/connection/local.py | 3 +- lib/ansible/plugins/connection/ssh.py | 13 +---- lib/ansible/utils/display.py | 3 +- lib/ansible/utils/encrypt.py | 19 +++----- pyproject.toml | 4 +- .../targets/ansible-test-container/runme.py | 48 ++++--------------- .../ansible_test/_data/completion/remote.txt | 2 +- .../_util/target/common/constants.py | 2 +- .../_util/target/setup/bootstrap.sh | 16 +++---- test/sanity/ignore.txt | 1 - test/units/plugins/lookup/test_password.py | 10 +--- test/units/requirements.txt | 8 ++-- test/units/utils/test_encrypt.py | 8 +--- 18 files changed, 46 insertions(+), 125 deletions(-) diff --git a/.azure-pipelines/azure-pipelines.yml b/.azure-pipelines/azure-pipelines.yml index 7f5b86a1869..a8e360b019d 100644 --- a/.azure-pipelines/azure-pipelines.yml +++ b/.azure-pipelines/azure-pipelines.yml @@ -99,8 +99,10 @@ stages: test: rhel/9.7@3.9 - name: RHEL 9.7 py312 test: rhel/9.7@3.12 - - name: RHEL 10.1 - test: rhel/10.1 + - name: RHEL 10.1 py312 + test: rhel/10.1@3.12 + - name: RHEL 10.1 py314 + test: rhel/10.1@3.14 - name: FreeBSD 14.4 test: freebsd/14.4 - name: FreeBSD 15.0 @@ -119,8 +121,6 @@ stages: test: rhel/10.1 - name: FreeBSD 14.4 test: freebsd/14.4 - - name: FreeBSD 15.0 - test: freebsd/15.0 groups: - 3 - 4 @@ -128,16 +128,10 @@ stages: - template: templates/matrix.yml # context/controller (ansible-test container management) parameters: targets: - - name: Alpine 3.23 - test: alpine/3.23 - name: Fedora 44 test: fedora/44 - - name: RHEL 9.7 - test: rhel/9.7 - name: RHEL 10.1 test: rhel/10.1 - - name: Ubuntu 24.04 - test: ubuntu/24.04 - name: Ubuntu 26.04 test: ubuntu/26.04 groups: @@ -174,8 +168,6 @@ stages: parameters: testFormat: linux/{0} targets: - - name: Alpine 3.23 - test: alpine323 - name: Fedora 44 test: fedora44 - name: Ubuntu 24.04 @@ -211,7 +203,6 @@ stages: nameFormat: Python {0} testFormat: galaxy/{0}/1 targets: - - test: 3.12 - test: 3.13 - test: 3.14 - test: 3.15 @@ -223,7 +214,6 @@ stages: nameFormat: Python {0} testFormat: generic/{0}/1 targets: - - test: 3.12 - test: 3.13 - test: 3.14 - test: 3.15 diff --git a/changelogs/fragments/python-support.yml b/changelogs/fragments/python-support.yml index 9f2eb5829cd..09df3bb1676 100644 --- a/changelogs/fragments/python-support.yml +++ b/changelogs/fragments/python-support.yml @@ -1,2 +1,3 @@ major_changes: - ansible - Add support for Python 3.15. + - ansible - Drop support for Python 3.12 on the controller. diff --git a/lib/ansible/cli/__init__.py b/lib/ansible/cli/__init__.py index 385bac25187..65dd661cb03 100644 --- a/lib/ansible/cli/__init__.py +++ b/lib/ansible/cli/__init__.py @@ -23,7 +23,7 @@ if 1 <= len(sys.argv) <= 2 and os.path.basename(sys.argv[0]) == "ansible" and os # Used for determining if the system is running a new enough python version # and should only restrict on our documented minimum versions -_PY_MIN = (3, 12) +_PY_MIN = (3, 13) if sys.version_info < _PY_MIN: raise SystemExit( diff --git a/lib/ansible/cli/_ssh_askpass.py b/lib/ansible/cli/_ssh_askpass.py index 47cb1299780..d886b89e0c6 100644 --- a/lib/ansible/cli/_ssh_askpass.py +++ b/lib/ansible/cli/_ssh_askpass.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import multiprocessing.resource_tracker import os import re import sys @@ -27,19 +26,10 @@ def handle_prompt(prompt: str) -> bool: sys.stdout.flush() return True - # deprecated: description='Python 3.13 and later support track' python_version='3.12' - can_track = sys.version_info[:2] >= (3, 13) - kwargs = dict(track=False) if can_track else {} - # This SharedMemory instance is intentionally not closed or unlinked. # Closing will occur naturally in the SharedMemory finalizer. # Unlinking is the responsibility of the process which created it. - shm = SharedMemory(name=os.environ['_ANSIBLE_SSH_ASKPASS_SHM'], **kwargs) - - if not can_track: - # When track=False is not available, we must unregister explicitly, since it otherwise only occurs during unlink. - # This avoids resource tracker noise on stderr during process exit. - multiprocessing.resource_tracker.unregister(shm._name, 'shared_memory') + shm = SharedMemory(name=os.environ['_ANSIBLE_SSH_ASKPASS_SHM'], track=False) cfg = json.loads(shm.buf.tobytes().rstrip(b'\x00')) diff --git a/lib/ansible/config/base.yml b/lib/ansible/config/base.yml index eb79dbdd27d..d034f8c8120 100644 --- a/lib/ansible/config/base.yml +++ b/lib/ansible/config/base.yml @@ -1672,6 +1672,7 @@ INTERPRETER_PYTHON: INTERPRETER_PYTHON_FALLBACK: name: Ordered list of Python interpreters to check for in discovery default: + - python3.15 - python3.14 - python3.13 - python3.12 diff --git a/lib/ansible/plugins/connection/local.py b/lib/ansible/plugins/connection/local.py index 5fff416e2a6..a999a77f14a 100644 --- a/lib/ansible/plugins/connection/local.py +++ b/lib/ansible/plugins/connection/local.py @@ -67,8 +67,7 @@ class Connection(ConnectionBase): self.cwd = None try: self.default_user = getpass.getuser() - except (ImportError, KeyError, OSError): - # deprecated: description='only OSError is required for Python 3.13+' python_version='3.12' + except OSError: display.vv("Current user (uid=%s) does not seem to exist on this system, leaving user empty." % os.getuid()) self.default_user = "" diff --git a/lib/ansible/plugins/connection/ssh.py b/lib/ansible/plugins/connection/ssh.py index 1171324365d..d75f5f64fdc 100644 --- a/lib/ansible/plugins/connection/ssh.py +++ b/lib/ansible/plugins/connection/ssh.py @@ -472,8 +472,6 @@ b_NOT_SSH_ERRORS = (b'Traceback (most recent call last):', # Python-2.6 when th SSHPASS_AVAILABLE = None SSH_DEBUG = re.compile(r'^debug\d+: .*') -_HAS_RESOURCE_TRACK = sys.version_info[:2] >= (3, 13) - PKCS11_DEFAULT_PROMPT = 'Enter PIN for ' SSH_ASKPASS_DEFAULT_PROMPT = 'assword' @@ -632,11 +630,6 @@ def _clean_resources(func): self.shm.close() with contextlib.suppress(FileNotFoundError): self.shm.unlink() - if not _HAS_RESOURCE_TRACK: - # deprecated: description='unneeded due to track argument for SharedMemory' python_version='3.12' - # There is a resource tracking issue where the resource is deleted, but tracking still has a record - # This will effectively overwrite the record and remove it - SharedMemory(name=self.shm.name, create=True, size=1).unlink() return ret return inner @@ -1038,11 +1031,7 @@ class Connection(ConnectionBase): if not conn_password: return popen_kwargs - kwargs = {} - if _HAS_RESOURCE_TRACK: - # deprecated: description='track argument for SharedMemory always available' python_version='3.12' - kwargs['track'] = False - self.shm = shm = SharedMemory(create=True, size=16384, **kwargs) # type: ignore[arg-type] + self.shm = shm = SharedMemory(create=True, size=16384, track=False) sshpass_prompt = self.get_option('sshpass_prompt') if not sshpass_prompt and pkcs11_provider: diff --git a/lib/ansible/utils/display.py b/lib/ansible/utils/display.py index b9bfa824742..6f61f00d9c9 100644 --- a/lib/ansible/utils/display.py +++ b/lib/ansible/utils/display.py @@ -174,8 +174,7 @@ class FilterUserInjector(logging.Filter): try: username = getpass.getuser() - except (ImportError, KeyError, OSError): - # deprecated: description='only OSError is required for Python 3.13+' python_version='3.12' + except OSError: # people like to make containers w/o actual valid passwd/shadow and use host uids username = 'uid=%s' % os.getuid() diff --git a/lib/ansible/utils/encrypt.py b/lib/ansible/utils/encrypt.py index 5043348e824..1f272b18327 100644 --- a/lib/ansible/utils/encrypt.py +++ b/lib/ansible/utils/encrypt.py @@ -6,7 +6,6 @@ from __future__ import annotations import random import secrets import string -import warnings from dataclasses import dataclass @@ -19,17 +18,13 @@ PASSLIB_E = None PASSLIB_AVAILABLE = False try: - # deprecated: description='warning suppression only required for Python 3.12 and earlier' python_version='3.12' - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', message="'crypt' is deprecated and slated for removal in Python 3.13", category=DeprecationWarning) - - import passlib - import passlib.hash - from passlib.utils.handlers import HasRawSalt, PrefixWrapper - try: - from passlib.utils.binary import bcrypt64 - except ImportError: - from passlib.utils import bcrypt64 + import passlib + import passlib.hash + from passlib.utils.handlers import HasRawSalt, PrefixWrapper + try: + from passlib.utils.binary import bcrypt64 + except ImportError: + from passlib.utils import bcrypt64 PASSLIB_AVAILABLE = True except Exception as e: diff --git a/pyproject.toml b/pyproject.toml index c1d8e68dc55..37b943ec151 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools >= 77.0.3, <= 80.3.1"] # lower bound to support license build-backend = "setuptools.build_meta" [project] -requires-python = ">=3.12" +requires-python = ">=3.13" name = "ansible-core" authors = [ {name = "Ansible Project"}, @@ -24,9 +24,9 @@ classifiers = [ "Natural Language :: English", "Operating System :: POSIX", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: 3 :: Only", "Topic :: System :: Installation/Setup", "Topic :: System :: Systems Administration", diff --git a/test/integration/targets/ansible-test-container/runme.py b/test/integration/targets/ansible-test-container/runme.py index ce7b3f37d67..7a9b342cc79 100755 --- a/test/integration/targets/ansible-test-container/runme.py +++ b/test/integration/targets/ansible-test-container/runme.py @@ -156,28 +156,13 @@ def get_test_scenarios() -> list[TestScenario]: image = settings['image'] cgroup = settings.get('cgroup', 'v1-v2') - if container_name == 'centos6' and os_release.id == 'alpine': - # Alpine kernels do not emulate vsyscall by default, which causes the centos6 container to fail during init. - # See: https://unix.stackexchange.com/questions/478387/running-a-centos-docker-image-on-arch-linux-exits-with-code-139 - # Other distributions enable settings which trap vsyscall by default. - # See: https://www.kernelconfig.io/config_legacy_vsyscall_xonly - # See: https://www.kernelconfig.io/config_legacy_vsyscall_emulate - continue - for engine in available_engines: # TODO: figure out how to get tests passing using docker without disabling selinux disable_selinux = os_release.id == 'fedora' and engine == 'docker' and cgroup != 'none' debug_systemd = cgroup != 'none' - # The sleep+pkill used to support the cgroup probe causes problems with the centos6 container. - # It results in sshd connections being refused or reset for many, but not all, container instances. - # The underlying cause of this issue is unknown. - probe_cgroups = container_name != 'centos6' - - # The default RHEL 9 crypto policy prevents use of SHA-1. - # This results in SSH errors with centos6 containers: ssh_dispatch_run_fatal: Connection to 1.2.3.4 port 22: error in libcrypto - # See: https://access.redhat.com/solutions/6816771 - enable_sha1 = os_release.id == 'rhel' and os_release.version_id.startswith('9.') and container_name == 'centos6' + if engine == 'docker' and container_name.startswith('alpine'): + continue # TODO: restore Docker testing of Alpine once it's able to be used as a controller again (probably Alpine 3.24) # The AppArmor policy for pasta on Ubuntu 26.04 prevents podman from stopping containers. # Attempting to do so fails with an error like: @@ -237,9 +222,7 @@ def get_test_scenarios() -> list[TestScenario]: image=image, disable_selinux=disable_selinux, expose_cgroup_version=expose_cgroup_version, - enable_sha1=enable_sha1, debug_systemd=debug_systemd, - probe_cgroups=probe_cgroups, disable_apparmor_profile_pasta=disable_apparmor_profile_pasta, ) ) @@ -255,16 +238,19 @@ def run_test(scenario: TestScenario) -> TestResult: integration = ['ansible-test', 'integration', 'split'] integration_options = ['--target', f'docker:{scenario.container_name}', '--color', '--truncate', '0', '-v'] - target_only_options = [] if scenario.debug_systemd: integration_options.append('--dev-systemd-debug') - if scenario.probe_cgroups: - target_only_options = ['--dev-probe-cgroups', str(LOG_PATH)] + target_only_options = ['--dev-probe-cgroups', str(LOG_PATH)] entries = get_container_completion_entries() - alpine_container = [name for name in entries if name.startswith('alpine')][0] + + # For the split test, Alpine Linux is preferred as the controller. There are two reasons for this: + # 1) It doesn't require the cgroup v1 hack, so we can test a target that doesn't need that. + # 2) It doesn't require disabling selinux, so we can test a target that doesn't need that. + # Unfortunately, this isn't always possible, such as when an Alpine release isn't available with support for controller Python versions. + controller_container = [name for name in entries if name.startswith('base')][0] commands = [ # The cgroup probe is only performed for the first test of the target. @@ -272,10 +258,7 @@ def run_test(scenario: TestScenario) -> TestResult: # The controller will be tested separately as a target. # This ensures that both the probe and no-probe code paths are functional. [*integration, *integration_options, *target_only_options], - # For the split test we'll use Alpine Linux as the controller. There are two reasons for this: - # 1) It doesn't require the cgroup v1 hack, so we can test a target that doesn't need that. - # 2) It doesn't require disabling selinux, so we can test a target that doesn't need that. - [*integration, '--controller', f'docker:{alpine_container}', *integration_options], + [*integration, '--controller', f'docker:{controller_container}', *integration_options], ] common_env: dict[str, str] = {} @@ -332,9 +315,6 @@ def run_test(scenario: TestScenario) -> TestResult: if scenario.disable_selinux: run_command('setenforce', 'permissive') - if scenario.enable_sha1: - run_command('update-crypto-policies', '--set', 'DEFAULT:SHA1') - if scenario.disable_apparmor_profile_pasta: os.symlink('/etc/apparmor.d/usr.bin.pasta', '/etc/apparmor.d/disable/usr.bin.pasta') run_command('apparmor_parser', '-R', '/etc/apparmor.d/usr.bin.pasta') @@ -365,9 +345,6 @@ def run_test(scenario: TestScenario) -> TestResult: os.unlink('/etc/apparmor.d/disable/usr.bin.pasta') run_command('apparmor_parser', '/etc/apparmor.d/usr.bin.pasta') - if scenario.enable_sha1: - run_command('update-crypto-policies', '--set', 'DEFAULT') - if scenario.disable_selinux: run_command('setenforce', 'enforcing') @@ -621,9 +598,7 @@ class TestScenario: image: str disable_selinux: bool expose_cgroup_version: int | None - enable_sha1: bool debug_systemd: bool - probe_cgroups: bool disable_apparmor_profile_pasta: bool @property @@ -642,9 +617,6 @@ class TestScenario: if self.expose_cgroup_version is not None: tags.append(f'cgroup: {self.expose_cgroup_version}') - if self.enable_sha1: - tags.append('sha1: enabled') - if self.disable_apparmor_profile_pasta: tags.append('apparmor(pasta): disabled') diff --git a/test/lib/ansible_test/_data/completion/remote.txt b/test/lib/ansible_test/_data/completion/remote.txt index 91e6906abb7..a5adc9f6088 100644 --- a/test/lib/ansible_test/_data/completion/remote.txt +++ b/test/lib/ansible_test/_data/completion/remote.txt @@ -9,7 +9,7 @@ macos/26.3 python=3.14 python_dir=/usr/local/bin become=sudo provider=mac arch=a macos python_dir=/usr/local/bin become=sudo provider=mac arch=aarch64 rhel/8.10 python=3.12 become=sudo provider=aws arch=x86_64 alias=rhel/8 rhel/9.7 python=3.9,3.12 become=sudo provider=aws arch=x86_64 alias=rhel/9 -rhel/10.1 python=3.12 become=sudo provider=aws arch=x86_64 alias=rhel/10,rhel/latest +rhel/10.1 python=3.12,3.14 become=sudo provider=aws arch=x86_64 alias=rhel/10,rhel/latest rhel become=sudo provider=aws arch=x86_64 ubuntu/24.04 python=3.12 become=sudo provider=aws arch=x86_64 ubuntu/26.04 python=3.14 become=sudo provider=aws arch=x86_64 alias=ubuntu/latest diff --git a/test/lib/ansible_test/_util/target/common/constants.py b/test/lib/ansible_test/_util/target/common/constants.py index c19dc7cd984..1d361752eeb 100644 --- a/test/lib/ansible_test/_util/target/common/constants.py +++ b/test/lib/ansible_test/_util/target/common/constants.py @@ -8,10 +8,10 @@ REMOTE_ONLY_PYTHON_VERSIONS = ( '3.9', '3.10', '3.11', + '3.12', ) CONTROLLER_PYTHON_VERSIONS = ( - '3.12', '3.13', '3.14', '3.15', diff --git a/test/lib/ansible_test/_util/target/setup/bootstrap.sh b/test/lib/ansible_test/_util/target/setup/bootstrap.sh index bbfaa0cd9e9..b581f8220ce 100644 --- a/test/lib/ansible_test/_util/target/setup/bootstrap.sh +++ b/test/lib/ansible_test/_util/target/setup/bootstrap.sh @@ -305,11 +305,6 @@ bootstrap_remote_freebsd() cryptography_pkg="" # not available pyyaml_pkg="" # not available ;; - "15.0/3.12") - jinja2_pkg="" # not available - cryptography_pkg="" # not available - pyyaml_pkg="" # not available - ;; *) # just assume nothing is available jinja2_pkg="" # not available @@ -429,7 +424,11 @@ bootstrap_remote_rhel_10() { optimize_dnf - py_pkg_prefix="python3" + if [ "${python_version}" = "3.12" ]; then + py_pkg_prefix="python3" + else + py_pkg_prefix="python${python_version}" + fi packages=" gcc @@ -437,14 +436,13 @@ bootstrap_remote_rhel_10() ${py_pkg_prefix}-pip " + # Jinja2, packaging and resolvelib are missing for controller supported Python versions, so we just + # skip them and let ansible-test install them from PyPI. if [ "${controller}" ]; then packages=" ${packages} ${py_pkg_prefix}-cryptography - ${py_pkg_prefix}-jinja2 - ${py_pkg_prefix}-packaging ${py_pkg_prefix}-pyyaml - ${py_pkg_prefix}-resolvelib " fi diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index 6449fc022b8..363d4077aab 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -65,7 +65,6 @@ lib/ansible/plugins/cache/base.py ansible-doc!skip # not a plugin, but a stub f lib/ansible/plugins/callback/__init__.py pylint:arguments-renamed lib/ansible/plugins/inventory/advanced_host_list.py pylint:arguments-renamed lib/ansible/plugins/inventory/host_list.py pylint:arguments-renamed -lib/ansible/_internal/_wrapt.py mypy-3.12!skip # vendored code lib/ansible/_internal/_wrapt.py mypy-3.13!skip # vendored code lib/ansible/_internal/_wrapt.py mypy-3.14!skip # vendored code lib/ansible/_internal/_wrapt.py mypy-3.15!skip # vendored code diff --git a/test/units/plugins/lookup/test_password.py b/test/units/plugins/lookup/test_password.py index ff2511a6530..34ea765068d 100644 --- a/test/units/plugins/lookup/test_password.py +++ b/test/units/plugins/lookup/test_password.py @@ -18,15 +18,9 @@ from __future__ import annotations -import warnings - try: - # deprecated: description='warning suppression only required for Python 3.12 and earlier' python_version='3.12' - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', message="'crypt' is deprecated and slated for removal in Python 3.13", category=DeprecationWarning) - - import passlib - from passlib.handlers import pbkdf2 + import passlib + from passlib.handlers import pbkdf2 except ImportError: # pragma: nocover passlib = None pbkdf2 = None diff --git a/test/units/requirements.txt b/test/units/requirements.txt index 43bd7bda7e7..12ae102462b 100644 --- a/test/units/requirements.txt +++ b/test/units/requirements.txt @@ -1,5 +1,5 @@ -bcrypt < 5 ; python_version >= '3.12' # controller only, bcrypt 5+ not compatible with passlib -passlib ; python_version >= '3.12' # controller only -pexpect ; python_version >= '3.12' # controller only -pywinrm ; python_version >= '3.12' # controller only +bcrypt < 5 ; python_version >= '3.13' # controller only, bcrypt 5+ not compatible with passlib +passlib ; python_version >= '3.13' # controller only +pexpect ; python_version >= '3.13' # controller only +pywinrm ; python_version >= '3.13' # controller only typing_extensions; python_version < '3.11' # some unit tests need Annotated and get_type_hints(include_extras=True) diff --git a/test/units/utils/test_encrypt.py b/test/units/utils/test_encrypt.py index 3c48bf61fff..848cf362f97 100644 --- a/test/units/utils/test_encrypt.py +++ b/test/units/utils/test_encrypt.py @@ -3,8 +3,6 @@ from __future__ import annotations -import warnings - import pytest from pytest_mock import MockerFixture @@ -228,11 +226,7 @@ def test_random_salt(): def test_passlib_bcrypt_salt(recwarn): - # deprecated: description='warning suppression only required for Python 3.12 and earlier' python_version='3.12' - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', message="'crypt' is deprecated and slated for removal in Python 3.13", category=DeprecationWarning) - - passlib_exc = pytest.importorskip("passlib.exc") + passlib_exc = pytest.importorskip("passlib.exc") secret = 'foo' salt = '1234567890123456789012'