Stop the loader from shadowing top-level imports via sys.path (#69139)#69787
Open
ggiesen wants to merge 2 commits into
Open
Stop the loader from shadowing top-level imports via sys.path (#69139)#69787ggiesen wants to merge 2 commits into
ggiesen wants to merge 2 commits into
Conversation
e842aa8 to
de280ad
Compare
…ack#69139) The LazyLoader put Salt's own source directories on the global sys.path while a module body executed (__populate_sys_path, and the fpath_dirname append in _load_module). Any bare import issued while that module ran could then resolve to a same-named single-file Salt module and get cached in sys.modules for the life of the process. That is what broke napalm on modern Salt. Loading salt/utils/napalm.py runs its top-level `import napalm`, which reaches ncclient.transport, which does a bare `import ssh` to detect the optional ssh-python/libssh package. With salt/utils on sys.path that bound to salt/utils/ssh.py (a plain module, not the ssh-python package), so ncclient's `from ssh.channel import Channel` raised "'ssh' is not a package", `import napalm` failed, HAS_NAPALM was False, and the napalm proxy/execution modules never passed their __virtual__ gate. This is the root cause behind the "Proxymodule napalm is missing an init()" reports in saltstack#69139 (which saltstack#69330 only improved the error message for). Salt-internal modules are imported via their fully-qualified salt.* names, so they never needed sys.path; only external/custom module dirs do, so a custom module's bare sibling imports keep resolving. Skip appending any directory under SALT_BASE_PATH in both __populate_sys_path and the fpath_dirname append. As a side effect, a module whose optional same-named dependency is not installed no longer "loads" by importing itself.
de280ad to
f72bf1b
Compare
3 tasks
…ack#69139) The unit tests monkeypatch SALT_BASE_PATH and use a synthetic shadow file. This drives the real minion_mods + utils loaders against the real SALT_BASE_PATH and asserts, from inside a module body executed mid-load, that no Salt-internal directory is ever placed on sys.path -- the name-independent guarantee that protects every salt/utils and salt/modules collision (dns, napalm, git, pip, consul, ...), not just ssh. The concrete salt/utils/ssh.py shadow is also asserted when a real top-level ssh is absent.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Stops Salt's module loader from putting its own source directories on the
global
sys.pathwhile a module body executes, which let a same-namedsingle-file Salt module shadow a top-level third-party package.
LazyLoaderappendedextra_module_dirs(__populate_sys_path) and themodule's own
fpath_dirname(_load_module) tosys.pathfor the duration ofa load. Any bare
importissued while that module ran could then resolve to asame-named file under
salt/utils,salt/modules, etc., and get cached insys.modulesfor the life of the process.This is the root cause behind the "Proxymodule napalm is missing an init()"
reports in #69139. Loading
salt/utils/napalm.pyruns its top-levelimport napalm, which reachesncclient.transport, which does a bareimport sshto detect the optional ssh-python/libssh package. Withsalt/utilson
sys.paththat bound tosalt/utils/ssh.py(a plain module, not thessh-python package), so ncclient's
from ssh.channel import Channelraised'ssh' is not a package,import napalmfailed,HAS_NAPALMwasFalse, andthe napalm modules never passed their
__virtual__gate. #69330 improved theerror message for this symptom; this fixes the underlying loader behaviour.
The fix: Salt-internal modules are imported via their fully-qualified
salt.*names, so they never needed
sys.path; only external/custom module dirs do (soa custom module's bare sibling imports still resolve). Skip appending any
directory at or under
SALT_BASE_PATHin both__populate_sys_pathand thefpath_dirnameappend. The existingfinallycleanup is unchanged.The internal-vs-external test uses a path-component boundary
(
_is_salt_internal_path), not a raw string prefix, so a directory whose namemerely begins with the salt package name -- notably the officially-supported
saltext.*extensions, which install sibling to thesaltpackage insite-packages (
.../site-packages/saltext/...) -- is correctly treated asexternal and keeps its
sys.pathentry.Blast radius
This intentionally removes all Salt-internal directories from
sys.path,not just
salt/utils, because the collision surface is general.The colliding stems people are likely to hit are third-party packages, not
Salt's own dependencies.
salt/utils/alone shadowsdns(dnspython),napalm,github(PyGithub),slack, andvault(besidesssh), andsalt/modules/addsgit(GitPython),pip,consul,elasticsearch, andtelegram, among others.The trigger is narrower than the surface, though, and worth being precise
about: the loader appends these directories, so a package that is
installed is still found in
site-packagesfirst and is not shadowed (Iconfirmed
import yamlandimport msgpackbind to the real packages even withsalt/utilsleaked ontosys.path). The actual failure mode is a bareimport Xfor a name that is not installed but matches a Salt stem -- anoptional-dependency probe (ncclient's
import sshdetecting libssh is thereported case), or one of the self-import modules below. Removing the leak
entirely closes the whole class regardless of import ordering.
On a normal install this changes nothing about which modules load. I compared
the full set of loadable modules (execution/state/render/serializer/output/
grain/pillar/sdb/proxy) with the stock loader vs this loader on a real 3006.26
onedir install (Rocky 9): no module started or stopped loading.
There is one behaviour change, and it is environment-dependent. 32 Salt modules
bare-import their own name (a module named after its optional dependency), e.g.
salt/modules/ethtool.pydoesimport ethtool. When that dependency is notinstalled, the module's own directory being on
sys.pathcan makeimport ethtoolresolve tosalt/modules/ethtool.pyitself -- a fragilecircular self-import that leaves the module self-referential and non-functional.
Whether that self-import actually "succeeds" (and the broken module loads)
depends on the interpreter/install layout: it happened in a source/
PYTHONPATHcheckout on Python 3.12 but not on the Python 3.11 onedir above (where those
modules simply do not load, dependency absent, with or without this change).
Where it does happen, the fix makes the module correctly not load instead of
self-shadow-loading. In every case, a module whose dependency is installed is
unaffected -- the real package is imported.
What issues does this PR fix or reference?
Fixes #69139
Previous Behavior
import napalm(and any third-party import whose top-level name collides with aSalt module stem) could be shadowed by a Salt module during loading, breaking
the affected modules.
New Behavior
Salt-internal directories are never placed on
sys.path, so a loaded module'simport chain resolves top-level names to the real installed packages. napalm
loads and its proxies initialise. External/custom module dirs (including
saltext.*extensions) are still added, so their sibling imports resolve.Merge requirements satisfied?
tests/pytests/unit/loader/test_loader.py:test_loader_does_not_shadow_top_level_import-- cross-shadow (napalm/sshclass); fails without the fix.
test_loader_self_named_module_not_self_shadowed_when_dep_absent-- theethtool/dson/json5 class; fails without the fix.
test_loader_self_named_module_loads_via_real_dep_when_present-- the samemodule still loads and binds to the real package when installed.
test_loader_external_module_dir_still_on_sys_path-- external/custom dirsare still appended.
test_loader_external_dir_sharing_salt_prefix_still_appended-- a dir whosepath merely begins with the salt install path (the
saltextboundary) isstill treated as external; fails with a raw
startswithguard.tests/pytests/functional/loader/test_syspath_shadow.py:test_loader_never_leaks_salt_internal_dirs_onto_sys_path_69139-- drives thereal
minion_mods+utilsloaders against the realSALT_BASE_PATH(the unittests monkeypatch it) and asserts, from inside a module body executed
mid-load, that no Salt-internal directory is on
sys.pathat all -- thename-independent guarantee covering the whole collision class -- plus the
concrete
salt/utils/ssh.pyshadow when a real top-levelsshis absent.Fails without the fix.
Validated end to end on a real 3006.26 onedir install (Rocky 9) running a
napalm deltaproxy against live Junos devices: with the pinned, otherwise-broken
ncclient==0.7.0still installed, napalm loads and the sub-proxies initialisewith no
KeyError: 'napalm.init'; reverting the change reproduces the failure.Commits signed with GPG?
No