Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/64433.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added dynamic loading of file_roots, pillar_roots, and thorium_roots to salt config
43 changes: 43 additions & 0 deletions doc/ref/configuration/master.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3654,10 +3654,40 @@ is equivalent to this static configuration:
prod:
- /srv/prod/salt

As of 3008.0, the ``file_roots`` option is dynamically expanded on each use,
meaning directories or files may be added without restarting the Salt master.
For instance, this configuration:

.. code-block:: yaml

file_roots:
base:
- /srv/salt/*-formula

may be used to dynamically add new formula directories to the ``file_roots``
without restarting the master, as long as their name matches ``*-formula``.

.. note::
For masterless Salt, this parameter must be specified in the minion config
file.

.. conf_master:: dynamic_roots_ttl

``dynamic_roots_ttl``
**********************

.. versionadded:: 3008.0

Default: ``5.0``

The number of seconds a dynamically-expanded (globbed) entry in
:conf_master:`file_roots`, :conf_master:`pillar_roots`, or ``thorium_roots``
is cached before the directory is re-scanned. Since globbed roots are
re-evaluated on every access, environments with many saltenvs or a busy
master accessing these options repeatedly (e.g. during a highstate) can
incur significant, avoidable disk I/O without this cache. Set to ``0`` to
disable caching and always re-scan on every access.

.. conf_master:: roots_update_interval

``roots_update_interval``
Expand Down Expand Up @@ -4841,6 +4871,19 @@ is equivalent to this static configuration:
prod:
- /srv/prod/pillar

As of 3008.0, the ``pillar_roots`` option is dynamically expanded on each use,
meaning directories or files may be added without restarting the Salt master.
For instance, this configuration:

.. code-block:: yaml

pillar_roots:
base:
- /srv/salt/*-pillar

may be used to dynamically add new pillar directories to the ``pillar_roots``
without restarting the master, as long as their name matches ``*-pillar``.

.. conf_master:: on_demand_ext_pillar

``on_demand_ext_pillar``
Expand Down
86 changes: 43 additions & 43 deletions salt/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import salt.syspaths
import salt.utils.data
import salt.utils.dictupdate
import salt.utils.dynamic_dict
import salt.utils.files
import salt.utils.immutabletypes as immutabletypes
import salt.utils.network
Expand Down Expand Up @@ -314,6 +315,9 @@ def _gather_buffer_space():
"file_roots": dict,
# A map of saltenvs and fileserver backend locations
"pillar_roots": dict,
# Number of seconds to cache the dynamically-expanded (globbed) entries
# of file_roots, pillar_roots, and thorium_roots before re-scanning disk
"dynamic_roots_ttl": (float, int),
# The external pillars permitted to be used on-demand using pillar.ext
"on_demand_ext_pillar": list,
# A map of glob paths to be used
Expand Down Expand Up @@ -1201,6 +1205,7 @@ def _gather_buffer_space():
"file_roots": {
"base": [salt.syspaths.BASE_FILE_ROOTS_DIR, salt.syspaths.SPM_FORMULA_PATH]
},
"dynamic_roots_ttl": 5.0,
"top_file_merging_strategy": "merge",
"env_order": [],
"default_top": "base",
Expand Down Expand Up @@ -1544,6 +1549,7 @@ def _gather_buffer_space():
"file_roots": {
"base": [salt.syspaths.BASE_FILE_ROOTS_DIR, salt.syspaths.SPM_FORMULA_PATH]
},
"dynamic_roots_ttl": 5.0,
"master_roots": {"base": [salt.syspaths.BASE_MASTER_ROOTS_DIR]},
"pillar_roots": {
"base": [salt.syspaths.BASE_PILLAR_ROOTS_DIR, salt.syspaths.SPM_PILLAR_PATH]
Expand Down Expand Up @@ -2095,55 +2101,45 @@ def _gather_buffer_space():
# <---- Salt Cloud Configuration Defaults ------------------------------------


def _normalize_roots(file_roots):
def _validate_roots(opts, prop_name):
"""
Normalize file or pillar roots.
If the roots option has a key that is None then we will log a warning and
use the defaults instead.
"""
for saltenv, dirs in file_roots.items():
normalized_saltenv = str(saltenv)
if normalized_saltenv != saltenv:
Comment thread
Ch3LL marked this conversation as resolved.
file_roots[normalized_saltenv] = file_roots.pop(saltenv)
if not isinstance(dirs, (list, tuple)):
file_roots[normalized_saltenv] = []
file_roots[normalized_saltenv] = _expand_glob_path(
file_roots[normalized_saltenv]
)
return file_roots


def _validate_pillar_roots(pillar_roots):
"""
If the pillar_roots option has a key that is None then we will error out,
just replace it with an empty list
"""
if not isinstance(pillar_roots, dict):
roots = opts.get(prop_name)
if not isinstance(roots, dict):
log.warning(
"The pillar_roots parameter is not properly formatted, using defaults"
"The %s parameter is not properly formatted, using defaults",
prop_name,
)
return {"base": _expand_glob_path([salt.syspaths.BASE_PILLAR_ROOTS_DIR])}
return _normalize_roots(pillar_roots)
return {"base": _expand_glob_path(DEFAULT_MASTER_OPTS.get(prop_name)["base"])}
# Use a dynamic dict to resolve file roots dynamically
result = salt.utils.dynamic_dict.DynamicDict(
ttl=opts.get("dynamic_roots_ttl", DEFAULT_MASTER_OPTS["dynamic_roots_ttl"])
)
for saltenv, dirs in roots.items():
normalized_saltenv = str(saltenv)
if not isinstance(dirs, (list, tuple)):
dirs = []
result.add_dyn(normalized_saltenv, _expand_glob_path, dirs)
return result


def _validate_file_roots(file_roots):
def _expand_glob_path(dirs, dyn_dict=None, key=None):
"""
If the file_roots option has a key that is None then we will error out,
just replace it with an empty list
A dynamic dict function that applies shell globbing to a set of
directories and returns the expanded paths
"""
if not isinstance(file_roots, dict):
log.warning(
"The file_roots parameter is not properly formatted, using defaults"
)
return {"base": _expand_glob_path([salt.syspaths.BASE_FILE_ROOTS_DIR])}
return _normalize_roots(file_roots)
# Unused arguments from dynamic dict
_ = dyn_dict
_ = key
# At times, this method is called with non-iterable objects,
# so just return them directly
if not isinstance(dirs, (list, tuple)):
return dirs


def _expand_glob_path(file_roots):
"""
Applies shell globbing to a set of directories and returns
the expanded paths
"""
unglobbed_path = []
for path in file_roots:
for path in dirs:
try:
if glob.has_magic(path):
unglobbed_path.extend(glob.glob(path))
Expand Down Expand Up @@ -2899,12 +2895,16 @@ def apply_sdb(opts, sdb_opts=None, _visited=None):

return salt.utils.sdb.sdb_get(sdb_opts, opts)
elif isinstance(sdb_opts, dict):
is_dyn_dict = isinstance(sdb_opts, salt.utils.dynamic_dict.DynamicDict)
# Create a list of items to avoid modifying dict during iteration
# This is especially important for OptsDict which has special iteration behavior
items = list(sdb_opts.items())
for key, value in items:
if value is None:
continue
if is_dyn_dict and sdb_opts.is_dyn_key(key):
# Nothing to do for dynamically generated elements
continue
sdb_opts[key] = apply_sdb(opts, value, _visited)
elif isinstance(sdb_opts, list):
for key, value in enumerate(sdb_opts):
Expand Down Expand Up @@ -4181,8 +4181,8 @@ def apply_minion_config(
# Enabling open mode requires that the value be set to True, and
# nothing else!
opts["open_mode"] = opts["open_mode"] is True
opts["file_roots"] = _validate_file_roots(opts["file_roots"])
opts["pillar_roots"] = _validate_pillar_roots(opts["pillar_roots"])
opts["file_roots"] = _validate_roots(opts, "file_roots")
opts["pillar_roots"] = _validate_roots(opts, "pillar_roots")
# Make sure ext_mods gets set if it is an untrue value
# (here to catch older bad configs)
opts["extension_modules"] = opts.get("extension_modules") or os.path.join(
Expand Down Expand Up @@ -4481,8 +4481,8 @@ def apply_master_config(overrides=None, defaults=None):
# nothing else!
opts["open_mode"] = opts["open_mode"] is True
opts["auto_accept"] = opts["auto_accept"] is True
opts["file_roots"] = _validate_file_roots(opts["file_roots"])
opts["pillar_roots"] = _validate_file_roots(opts["pillar_roots"])
opts["file_roots"] = _validate_roots(opts, "file_roots")
opts["pillar_roots"] = _validate_roots(opts, "pillar_roots")

if opts["file_ignore_regex"]:
# If file_ignore_regex was given, make sure it's wrapped in a list.
Expand Down
3 changes: 2 additions & 1 deletion salt/thorium/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import salt.loader
import salt.payload
import salt.state
from salt.config import _validate_roots
from salt.exceptions import SaltRenderError

log = logging.getLogger(__name__)
Expand All @@ -36,7 +37,7 @@ def __init__(
self.grain_keys = grain_keys
self.pillar = pillar
self.pillar_keys = pillar_keys
opts["file_roots"] = opts["thorium_roots"]
opts["file_roots"] = _validate_roots(opts, "thorium_roots")
opts["saltenv"] = opts["thoriumenv"]
opts["state_top"] = opts["thorium_top"]
opts["file_client"] = "local"
Expand Down
111 changes: 111 additions & 0 deletions salt/utils/dynamic_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""
A dictionary with optionally dynamic values, used for dynamic configuration such
as file roots.
"""

import copy
import time

__all__ = ["DynamicDict"]

#: Default number of seconds a dynamic value is cached before being
#: re-evaluated. Callers (e.g. ``salt.config``) may override this per
#: instance via the ``ttl`` argument.
DEFAULT_TTL = 5.0


class DynamicDict(dict):
"""
A dictionary that can mix static and dynamic values.
"""

def __init__(self, *args, ttl=DEFAULT_TTL, **argv):
self._func_dict = {}
self._cache = {}
self._ttl = ttl
super().__init__(*args, **argv)

def __getitem__(self, key):
val = super().__getitem__(key)
if key in self._func_dict:
now = time.time()
cached = self._cache.get(key)
if self._ttl and cached is not None and (now - cached[1]) < self._ttl:
return cached[0]
val = self._func_dict[key](val, dyn_dict=self, key=key)
self._cache[key] = (val, now)
return val
Comment on lines +28 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical Performance Bug: Uncached Disk I/O on Dictionary Access
The Issue: Every time a core Salt subsystem reads an environment path (e.g., checking opts["file_roots"]["base"]), __getitem__ triggers the _expand_glob_path callback. This function performs expensive, blocking file system I/O using glob.glob(path).

The Impact: During a highstate run or compilation, the options dictionary is accessed hundreds or thousands of times. Running uncached disk globbing loops repeatedly on every single key lookup will severely degrade master performance at scale.

The Fix: Implement a short-lived cache (TTL mechanism) inside DynamicDict so that the expensive glob evaluation hits the physical disk at a reasonable interval rather than on every single key read.

A clean way to solve this is to add an internal cache dictionary inside DynamicDict that tracks a timestamp alongside the evaluated paths, limiting disk scans to once every few seconds.

Modify the DynamicDict class in salt/utils/dynamic_dict.py:

import time
import glob

class DynamicDict(dict):
    def __init__(self, *args, **argv):
        self._func_dict = {}
        self._cache = {}
        self._ttl = 5.0  # Cache disk globs for 5 seconds
        super().__init__(*args, **argv)

    def __getitem__(self, key):
        val = super().__getitem__(key)
        if key in self._func_dict:
            now = time.time()
            # If cache is expired or missing, re-evaluate
            if key not in self._cache or (now - self._cache[key]["ts"]) > self._ttl:
                evaluated_val = self._func_dict[key](val, dyn_dict=self, key=key)
                self._cache[key] = {"val": evaluated_val, "ts": now}
            
            return self._cache[key]["val"]
        return val

    def __delitem__(self, key):
        if key in self._func_dict:
            del self._func_dict[key]
        if key in self._cache:
            del self._cache[key]
        super().__delitem__(key)

A Better Architectural Approach
Instead of a hardcoded time-based TTL, a cleaner, more reliable approach for Salt config structures is to use a cached property that flushes when the loader reloads, or a slightly longer, configurable default window (like 5 to 10 seconds).

If you want to keep the time-based approach but make it resilient against mid-job expiration, you can expose the TTL as a master configuration option that defaults to 5.0 seconds:

self._ttl = opts.get("dynamic_dict_ttl", 5.0)

This gives you a safer buffer for long-running compilations while allowing teams with massive environments to turn it up if their disk I/O becomes a bottleneck.


def __delitem__(self, key):
if key in self._func_dict:
del self._func_dict[key]
self._cache.pop(key, None)
super().__delitem__(key)

def get(self, key, default=None):
if key not in self:
return default
return self[key]

def pop(self, key, default=None):
if key in self:
val = self[key]
del self[key]
else:
val = default
return val

def values(self):
keys = super().keys()
for key in keys:
yield self[key]
Comment on lines +58 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical API Mismatch: .items() Omission and Broken Dictionary Views

The Issue: The DynamicDict implementation completely fails to override .items()`, and it implements .values()`` as a custom generator rather than a standard Python 3 dictionary view object (dict_values).

The Impact: Because .items() falls back to super().items(), running loops like for env, paths in opts["file_roots"].items(): bypasses the dynamic wrapper entirely, returning the raw, unglobbed static strings instead of the expanded paths. Furthermore, calling list(sdb_opts.items()) in functions like apply_sdb fails to evaluate the dynamic keys, while code expecting standard dictionary view behaviors (such as set operations) will break when interacting with a raw generator.

The Fix: Explicitly override keys(), values(), and items() inside DynamicDict to evaluate the dynamic pathways cleanly and ensure parity with standard Python 3 dictionary behaviors.

Add the following methods to the DynamicDict class in salt/utils/dynamic_dict.py, replacing the existing values() generator:

    def keys(self):
        return super().keys()

    def values(self):
        # Return a custom list-like view or tuple list to mirror dict_values
        return [self[key] for key in super().keys()]

    def items(self):
        # Crucial fix: Ensures loops over .items() evaluate the dynamic paths
        return [(key, self[key]) for key in super().keys()]


def items(self):
keys = super().keys()
for key in keys:
yield key, self[key]

def copy(self):
new_dd = DynamicDict(ttl=self._ttl)
for key, val in super().items():
if key in self._func_dict:
func = self._func_dict[key]
data = super().__getitem__(key)
new_dd.add_dyn(key, func, data)
else:
new_dd[key] = val
return new_dd

def __copy__(self):
return self.copy()

def __deepcopy__(self, memo):
rdd = DynamicDict(ttl=self._ttl)
memo[id(self)] = rdd
for key in super().keys():
if key in self._func_dict:
func = self._func_dict[key]
data = copy.deepcopy(super().__getitem__(key), memo)
rdd.add_dyn(key, func, data)
else:
copied_key = copy.deepcopy(key, memo)
copied_value = copy.deepcopy(super().__getitem__(key), memo)
rdd[copied_key] = copied_value
return rdd

def static_dict(self):
new_dict = {}
for key in super().keys():
new_dict[key] = self[key]
return new_dict

def is_dyn_key(self, key):
return key in self._func_dict

def add_dyn(self, key, func, data=None):
if not hasattr(func, "__call__"):
raise ValueError(f"Value for key '{key}' is not a function")
self._func_dict[key] = func
self._cache.pop(key, None)
if data is not None or key not in self:
self[key] = data
4 changes: 4 additions & 0 deletions salt/utils/yamldumper.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import salt.utils.context
from salt.utils.datastructures import HashableOrderedDict
from salt.utils.dynamic_dict import DynamicDict
from salt.utils.optsdict import DictProxy, ListProxy, OptsDict
from salt.utils.secret import MaskedDict, MaskedList

Expand Down Expand Up @@ -162,6 +163,9 @@ def represent_listproxy(dumper, data):
"tag:yaml.org,2002:timestamp", SafeOrderedDumper.represent_scalar
)

OrderedDumper.add_representer(DynamicDict, represent_ordereddict)
SafeOrderedDumper.add_representer(DynamicDict, represent_ordereddict)


def get_dumper(dumper_name):
return {
Expand Down
4 changes: 4 additions & 0 deletions tests/filename_map.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ salt/state.py:
salt/utils/decorators/*:
- integration.modules.test_decorators

salt/utils/dynamic_dict.py:
- pytests.unit.utils.test_dynamic_dict

salt/(utils|renderers)/jinja\.py:
- pytests.functional.modules.state.test_jinja_filters
- integration.states.test_renderers
Expand Down Expand Up @@ -221,6 +224,7 @@ salt/client/ssh/wrapper/*:
salt/config/*:
- unit.test_config
- pytests.unit.config.test__validate_opts
- pytests.integration.master.test_dynamic_config

salt/loader/*:
- integration.loader.test_ext_modules
Expand Down
5 changes: 4 additions & 1 deletion tests/pytests/functional/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ def master_opts(

@pytest.fixture(scope="module")
def loaders(minion_opts):
return Loaders(minion_opts, loaded_base_name=f"{__name__}.loaded")
result = Loaders(minion_opts, loaded_base_name=f"{__name__}.loaded")
# Convert the file roots to a static dict before appending any roots
result.opts["file_roots"] = result.opts["file_roots"].static_dict()
return result


@pytest.fixture(autouse=True)
Expand Down
Loading
Loading