diff --git a/changelog/69795.fixed.md b/changelog/69795.fixed.md new file mode 100644 index 000000000000..f143cdc18c03 --- /dev/null +++ b/changelog/69795.fixed.md @@ -0,0 +1,5 @@ +Fixed two bugs in the ``napalm_network`` execution module. ``net.load_template`` +no longer crashes with ``AttributeError: 'NoneType' object has no attribute +'startswith'`` when rendering an inline ``template_source`` (no +``template_name``), and ``_config_logic`` now honours ``commit_at`` when +scheduling a commit instead of passing ``commit_in`` for both times. diff --git a/salt/modules/napalm_network.py b/salt/modules/napalm_network.py index d760d8319516..fb35b1eb0879 100644 --- a/salt/modules/napalm_network.py +++ b/salt/modules/napalm_network.py @@ -243,7 +243,7 @@ def _config_logic( # and there are changes to commit if commit_in or commit_at: commit_time = __utils__["timeutil.get_time_at"]( - time_in=commit_in, time_at=commit_in + time_in=commit_in, time_at=commit_at ) # schedule job scheduled_job_name = f"__napalm_commit_{current_jid}" @@ -1957,7 +1957,11 @@ def load_template( salt_render_prefixes = ("salt://", "http://", "https://", "ftp://") salt_render = False file_exists = False - if not isinstance(template_name, (tuple, list)): + # Only a single, named template goes through the salt:// / file precheck. + # ``template_name`` is ``None`` when rendering an inline ``template_source``, + # and calling ``None.startswith(...)`` here raised ``AttributeError``; a list + # of names is handled further down. + if isinstance(template_name, str): for salt_render_prefix in salt_render_prefixes: if not salt_render: salt_render = salt_render or template_name.startswith( diff --git a/tests/pytests/unit/modules/napalm/test_network.py b/tests/pytests/unit/modules/napalm/test_network.py index 2ce2d6e621b9..8243b4604b91 100644 --- a/tests/pytests/unit/modules/napalm/test_network.py +++ b/tests/pytests/unit/modules/napalm/test_network.py @@ -185,6 +185,43 @@ def test_load_template(): assert ret["out"] is None +def test_load_template_inline_source(): + # Rendering an inline ``template_source`` passes template_name=None; the + # salt:// precheck used to call ``None.startswith`` and crash. + with patch( + "salt.utils.napalm.get_device", + MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.dict( + napalm_network.__salt__, + {"file.apply_template_on_contents": MagicMock(return_value="new config")}, + ): + ret = napalm_network.load_template(template_source="system { host-name r1; }") + assert ret["result"] + + +def test_load_config_commit_at_uses_absolute_time(): + # Regression: commit_at was passed to get_time_at as ``time_at=commit_in``, + # so scheduling a commit at an absolute time was silently ignored. + get_time_at = MagicMock(return_value="2026-07-11T02:00:00") + with patch( + "salt.utils.napalm.get_device", + MagicMock(return_value=napalm_test_support.MockNapalmDevice()), + ), patch.dict(napalm_network.__opts__, {"id": "test-minion"}), patch.dict( + napalm_network.__utils__, {"timeutil.get_time_at": get_time_at} + ), patch.dict( + napalm_network.__salt__, + { + "schedule.add": MagicMock(return_value={"result": True, "comment": ""}), + "schedule.save": MagicMock(return_value={"result": True, "comment": ""}), + }, + ): + napalm_network.load_config(text="new config", commit_at="2026-07-11T02:00:00") + get_time_at.assert_called_once() + _, kwargs = get_time_at.call_args + assert kwargs["time_at"] == "2026-07-11T02:00:00" + assert kwargs["time_in"] is None + + def test_commit(): with patch( "salt.utils.napalm.get_device",