Bench bench-ansible-0001.py rewritten to model the real defect: Role-like objects with __eq__ but no __hash__, and to demonstrate that the naive list->set swap raises TypeError. bench_fixed uses a MockRole with both __eq__ and __hash__. Four scales D=100..2000, min of 3 trials per scale. Patch doc ansible-0001-role-get-vars-seen-list.md now describes the coupled change (add __hash__, then switch seen list to set), references the upstream PR branch and the integration target roles_var_inheritance. Added ansible-0001-role-get-vars-seen-list.patch (git-format-patch export from the upstream commit) with mandatory complexity-gate comment block.
113 lines
5.3 KiB
Diff
113 lines
5.3 KiB
Diff
# UNDF: UNDF-2026-000000005
|
|
# CWE-407: Algorithmic Complexity, O(D^2) -> O(D) in ansible.playbook.role.Role.get_vars()
|
|
#
|
|
# Defect: Role.get_vars() dedupes transitive deps with `seen = []` plus
|
|
# `dep not in seen`, O(D) per iteration. Total cost: O(D^2) over D transitive
|
|
# role dependencies. At D=2000: ~200ms per get_vars() call.
|
|
#
|
|
# Root cause: a naive `seen = set()` swap raises TypeError — Role defines
|
|
# __eq__ without __hash__, so instances are unhashable by default.
|
|
#
|
|
# Fix: add __hash__ on Role hashing (name, path) (a stable subset of
|
|
# _get_hash_dict equality fields), then swap `seen = []` for `seen = set()`
|
|
# and `seen.append` for `seen.add`. Total cost after: O(D).
|
|
# At D=2000: ~0.75ms per get_vars() call (~270x faster).
|
|
#
|
|
# Complexity gate (defects/ansible/bench/bench-ansible-0001.py):
|
|
# Four scales D=100,500,1000,2000, min of 3 trials per scale.
|
|
# Asserts naive list->set swap raises TypeError on MockRoleEqOnly.
|
|
# Speedup at D=2000 observed >= ~130x on CPython 3.12.
|
|
#
|
|
# Upstream tests (ansible.git):
|
|
# Unit: test/units/playbook/role/test_role.py::TestRole::test_role_is_hashable_and_set_dedupes
|
|
# Integration: test/integration/targets/roles_var_inheritance (shared common_dep -> nested_dep)
|
|
#
|
|
From 6a5f7d2596d24acaffb480808076fc6990353e02 Mon Sep 17 00:00:00 2001
|
|
From: "russell@unturf.com" <russell@unturf.com>
|
|
Date: Thu, 23 Apr 2026 12:56:21 -0400
|
|
Subject: [PATCH] playbook/role: dedupe dependency vars via set
|
|
|
|
Role.get_vars() previously used a list for `seen` dependency dedup,
|
|
making the membership check O(D) per iteration and total O(D^2) over
|
|
D transitive dependencies. Switch to a set, reducing to O(D).
|
|
|
|
Role defines __eq__ without __hash__ (implicitly unhashable), so add
|
|
__hash__ hashing (name, path) -- a stable subset of the equality
|
|
fields, preserving the eq/hash contract.
|
|
---
|
|
.../fragments/role-get-vars-seen-set.yml | 2 ++
|
|
lib/ansible/playbook/role/__init__.py | 8 ++++++--
|
|
test/units/playbook/role/test_role.py | 19 +++++++++++++++++++
|
|
3 files changed, 27 insertions(+), 2 deletions(-)
|
|
create mode 100644 changelogs/fragments/role-get-vars-seen-set.yml
|
|
|
|
diff --git a/changelogs/fragments/role-get-vars-seen-set.yml b/changelogs/fragments/role-get-vars-seen-set.yml
|
|
new file mode 100644
|
|
index 0000000..a3b6946
|
|
--- /dev/null
|
|
+++ b/changelogs/fragments/role-get-vars-seen-set.yml
|
|
@@ -0,0 +1,2 @@
|
|
+minor_changes:
|
|
+ - role - ``Role.get_vars()`` now deduplicates transitive dependencies via a set rather than a list, reducing complexity from O(D\ :sup:`2`\ ) to O(D); ``Role`` gains an ``__hash__`` method consistent with its existing ``__eq__``.
|
|
diff --git a/lib/ansible/playbook/role/__init__.py b/lib/ansible/playbook/role/__init__.py
|
|
index ab79c55..05a2bb3 100644
|
|
--- a/lib/ansible/playbook/role/__init__.py
|
|
+++ b/lib/ansible/playbook/role/__init__.py
|
|
@@ -205,6 +205,10 @@ class Role(Base, Conditional, Taggable, CollectionSearch, Delegatable):
|
|
|
|
return self._get_hash_dict() == other._get_hash_dict()
|
|
|
|
+ def __hash__(self):
|
|
+ # Subset of _get_hash_dict fields; any two roles that compare equal share the same (name, path).
|
|
+ return hash((self.get_name(), self.get_role_path()))
|
|
+
|
|
@staticmethod
|
|
def load(role_include, play, parent_role=None, from_files=None, from_include=False, validate=True, public=None, static=True, rescuable=True):
|
|
if from_files is None:
|
|
@@ -536,14 +540,14 @@ class Role(Base, Conditional, Taggable, CollectionSearch, Delegatable):
|
|
all_vars = self.get_inherited_vars(dep_chain, only_exports=only_exports)
|
|
|
|
# get exported variables from meta/dependencies
|
|
- seen = []
|
|
+ seen = set()
|
|
for dep in self.get_all_dependencies():
|
|
# Avoid rerunning dupe deps since they can have vars from previous invocations and they accumulate in deps
|
|
# TODO: re-examine dep loading to see if we are somehow improperly adding the same dep too many times
|
|
if dep not in seen:
|
|
# only take 'exportable' vars from deps
|
|
all_vars = combine_vars(all_vars, dep.get_vars(include_params=False, only_exports=True))
|
|
- seen.append(dep)
|
|
+ seen.add(dep)
|
|
|
|
# role_vars come from vars/ in a role
|
|
all_vars = combine_vars(all_vars, self._role_vars)
|
|
diff --git a/test/units/playbook/role/test_role.py b/test/units/playbook/role/test_role.py
|
|
index cbfe776..2163849 100644
|
|
--- a/test/units/playbook/role/test_role.py
|
|
+++ b/test/units/playbook/role/test_role.py
|
|
@@ -410,3 +410,22 @@ class TestRole(unittest.TestCase):
|
|
r = Role.load(i, play=mock_play)
|
|
|
|
self.assertEqual(r.get_name(), "foo_complex")
|
|
+
|
|
+ @patch('ansible.playbook.role.definition.unfrackpath', mock_unfrackpath_noop)
|
|
+ def test_role_is_hashable_and_set_dedupes(self):
|
|
+ fake_loader = DictDataLoader({
|
|
+ "/etc/ansible/roles/foo_hashable/tasks/main.yml": "- shell: echo hi",
|
|
+ })
|
|
+
|
|
+ mock_play = MagicMock()
|
|
+ mock_play.role_cache = {}
|
|
+
|
|
+ i1 = RoleInclude.load(dict(role='foo_hashable'), play=mock_play, loader=fake_loader)
|
|
+ r1 = Role.load(i1, play=mock_play)
|
|
+ i2 = RoleInclude.load(dict(role='foo_hashable'), play=mock_play, loader=fake_loader)
|
|
+ r2 = Role.load(i2, play=mock_play)
|
|
+
|
|
+ hash(r1)
|
|
+ self.assertEqual(r1, r2)
|
|
+ self.assertEqual(hash(r1), hash(r2))
|
|
+ self.assertEqual(len({r1, r2}), 1)
|
|
--
|
|
2.43.0
|
|
|