Fix minion crash when grains config option is empty - #69891
Conversation
An empty 'grains:' config option parses to None instead of a dict,
which crashed the minion during startup with "TypeError: 'NoneType'
object is not iterable" when the loader tried to build the
__grains__ NamespacedDictWrapper.
Default the option to an empty dict in both places that read it:
apply_minion_config, and salt.loader.grains(), which independently
re-reads the raw config file off disk. Log a warning in each case
pointing out that 'grains: {}' should be used instead.
Fixes saltstack#61321
378f500 to
3bb5529
Compare
Only warn/default the 'grains' config option when it is explicitly
present and None, not merely absent from opts. The looser
opts.get("grains") is None check could not distinguish "grains: set
to empty" from "grains key never set at all" (e.g. a sparse defaults
dict passed into apply_minion_config without a 'grains' key), causing
a false-positive warning in that case. This matches the equivalent
check already used in salt.loader.grains().
Also move log_file = factory.config["log_file"] in the new
integration test out of the try block, since it doesn't depend on the
test.ping call succeeding and reads more clearly next to the log file
assertions it's used for.
57f58c5 to
27d07da
Compare
27d07da to
20e9d04
Compare
There was a problem hiding this comment.
not isinstance(opts["grains"], dict) -- catches empty and wrong-type in one guard. No valid grains is anything but a mapping. Drop the warning; document the shape.
conf/minion above #grains::
+#
+# The value of 'grains' must be a mapping. Use 'grains: {}' for an
+# explicit empty section. Any non-dict value is silently defaulted
+# to an empty dict.
#grains:doc/ref/configuration/minion.rst inside the grains entry, after the example:
+The value of ``grains`` must be a mapping. Use ``grains: {}`` for an
+explicit empty section. Any non-dict value is silently defaulted to
+an empty dict.
+
.. conf_minion:: grains_blacklistTests: parametrize over ["", '""', "[]", "foo", "42", "[1, 2]"] (all defaulting to {}) in tests/unit/test_config.py + tests/pytests/unit/loader/test_loader.py; same for tests/pytests/integration/cli/test_salt_minion.py with [None, "", [], "foo", 42, [1, 2]], drop the log-file assertions and the import salt.utils.files.
| if "grains" in opts and opts["grains"] is None: | ||
| log.warning( | ||
| "Config option 'grains' is set to an empty value. An empty " | ||
| "'grains' config is invalid, a dict is required. To set an " | ||
| "empty grains config, use 'grains: {}' instead. Defaulting to " | ||
| "an empty dict." | ||
| ) | ||
| opts["grains"] = defaults.get("grains", {}) |
There was a problem hiding this comment.
not isinstance(..., dict) -- catches empty and wrong-type. Docs say a dict is required; every non-dict is equally invalid. Drop the warning; document in conf/minion + doc/ref/configuration/minion.rst instead.
| if "grains" in opts and opts["grains"] is None: | |
| log.warning( | |
| "Config option 'grains' is set to an empty value. An empty " | |
| "'grains' config is invalid, a dict is required. To set an " | |
| "empty grains config, use 'grains: {}' instead. Defaulting to " | |
| "an empty dict." | |
| ) | |
| opts["grains"] = defaults.get("grains", {}) | |
| if "grains" in opts and not isinstance(opts["grains"], dict): | |
| opts["grains"] = {} |
| if "grains" in pre_opts and pre_opts["grains"] is None: | ||
| log.warning( | ||
| "Config option 'grains' is set to an empty value. An empty " | ||
| "'grains' config is invalid, a dict is required. To set an " | ||
| "empty grains config, use 'grains: {}' instead. Defaulting " | ||
| "to an empty dict." | ||
| ) | ||
| if pre_opts.get("grains") is not None: | ||
| opts["grains"] = pre_opts["grains"] | ||
| else: | ||
| opts["grains"] = {} |
There was a problem hiding this comment.
Same shape here. Collapses the two-if structure.
| if "grains" in pre_opts and pre_opts["grains"] is None: | |
| log.warning( | |
| "Config option 'grains' is set to an empty value. An empty " | |
| "'grains' config is invalid, a dict is required. To set an " | |
| "empty grains config, use 'grains: {}' instead. Defaulting " | |
| "to an empty dict." | |
| ) | |
| if pre_opts.get("grains") is not None: | |
| opts["grains"] = pre_opts["grains"] | |
| else: | |
| opts["grains"] = {} | |
| if "grains" in pre_opts and isinstance(pre_opts["grains"], dict): | |
| opts["grains"] = pre_opts["grains"] | |
| else: | |
| opts["grains"] = {} |
| @@ -0,0 +1 @@ | |||
| Fixed minion crashing on startup when the ``grains`` config option was present but empty (e.g. ``grains:`` with no value), which previously caused a ``TypeError: 'NoneType' object is not iterable``. | |||
There was a problem hiding this comment.
Widen wording to match; mention the docs addition.
| Fixed minion crashing on startup when the ``grains`` config option was present but empty (e.g. ``grains:`` with no value), which previously caused a ``TypeError: 'NoneType' object is not iterable``. | |
| Fixed minion crashing on startup when the ``grains`` config option was present but not a mapping (e.g. ``grains:`` with no value, an empty string, or a scalar), which previously caused a ``TypeError: 'NoneType' object is not iterable`` and similar. Any non-dict value is now silently defaulted to an empty dict, and the required shape of the ``grains`` option is documented in the minion configuration reference. |
What does this PR do?
Fixes a minion crash on startup caused by an empty
grains:config option. YAML parsesgrains:with no value asNoneinstead of a dict, which two separate code paths (apply_minion_configandsalt.loader.grains(), which independently re-reads the raw config file off disk) assumed was always a dict. That crashed the minion withTypeError: 'NoneType' object is not iterablewhen building the__grains__context wrapper.Both locations now default the option to an empty dict (
{}) when it resolves toNone, and log a warning pointing out thatgrains: {}should be used instead of an emptygrains:key.What issues does this PR fix or reference?
Fixes #61321
Previous Behavior
A minion configured with an empty
grains:config option (e.g.grains:with no value) would crash on startup with:raised from
salt.utils.context.NamespacedDictWrapper.__init__via the loader's__prep_mod_opts.New Behavior
The minion starts normally. The empty
grainsoption is treated as{}, and a warning is logged:Merge requirements satisfied?
Add unit tests in
tests/unit/test_config.pyandtests/pytests/unit/loader/test_loader.py, plus an integration test intests/pytests/integration/cli/test_salt_minion.py.Commits signed with GPG?
No