summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authoromnom62 <75066712+omnom62@users.noreply.github.com>2026-09-16 22:32:53 +1000
committerGitHub <noreply@github.com>2026-09-16 15:32:53 +0300
commit762c276f61dd8fb599d9600df93c1a2992a8cf2e (patch)
tree1e0d1c381f4dd2ac722661b150d11c0c44221d81 /tests
parent25635b0560863eb4a5b34434bb34dfd7c43d77f9 (diff)
downloadvyos.vyos-762c276f61dd8fb599d9600df93c1a2992a8cf2e.tar.gz
vyos.vyos-762c276f61dd8fb599d9600df93c1a2992a8cf2e.zip
T6837: vyos_config replace (#493)HEADmain
* T6837: vyos_config replace core
Diffstat (limited to 'tests')
-rw-r--r--tests/integration/targets/vyos_config/templates/replace_config_candidate.cfg1
-rw-r--r--tests/integration/targets/vyos_config/tests/cli/replace_config.yaml289
-rw-r--r--tests/unit/modules/network/vyos/test_vyos_config.py208
3 files changed, 497 insertions, 1 deletions
diff --git a/tests/integration/targets/vyos_config/templates/replace_config_candidate.cfg b/tests/integration/targets/vyos_config/templates/replace_config_candidate.cfg
new file mode 100644
index 00000000..4a29cdea
--- /dev/null
+++ b/tests/integration/targets/vyos_config/templates/replace_config_candidate.cfg
@@ -0,0 +1 @@
+{{ replace_config_candidate_content }}
diff --git a/tests/integration/targets/vyos_config/tests/cli/replace_config.yaml b/tests/integration/targets/vyos_config/tests/cli/replace_config.yaml
new file mode 100644
index 00000000..1e208275
--- /dev/null
+++ b/tests/integration/targets/vyos_config/tests/cli/replace_config.yaml
@@ -0,0 +1,289 @@
+---
+- debug: msg="START cli/replace_config.yaml on connection={{ ansible_connection }}"
+
+# SAFETY NOTE: replace=config requires a COMPLETE configuration in
+# hierarchical/bracket format (the same format `show configuration` /
+# /config/config.boot use) -- never flat set/delete commands (VyOS's `load`
+# rejects those with a parse error) and never a partial candidate (anything
+# omitted is deleted, including management interfaces, SSH, and login
+# users). Every step below operates on a full baseline captured live from
+# the device itself, edited in place -- never a minimal hand-written or
+# statically-shipped candidate. This also sidesteps a real safety concern:
+# a checked-in static fixture would either need fake device-specific data
+# (interface hw-ids, password hashes) that's wrong for every real target,
+# or genuine credentials baked into git -- capturing live avoids both.
+#
+# NOTE on `src` / templates/replace_config_candidate.cfg: this file's
+# entire content is the single Jinja2 expression
+# "{{ replace_config_candidate_content }}". netcommon's action plugin
+# (_handle_src_option) reads whatever `src` points to and renders it as a
+# Jinja2 template using the play's own templar *before* the module runs --
+# this lets us point `src` at one static, checked-in, device-agnostic file
+# for every task below, and change only the in-memory fact
+# (replace_config_candidate_content) each time, with zero local file
+# writes and therefore no delegate_to/connection wrangling at all.
+# This mechanism is deprecated upstream (removal after 2028-01-01) in favor
+# of a `content:` module parameter -- cisco.iosxr.iosxr_config already has
+# one; vyos_config does not yet. Adding `content:` to vyos_config, mirroring
+# iosxr_config, would let this test drop the deprecated path entirely, but
+# that's real module-feature scope beyond this PR, not test plumbing.
+#
+# NOTE on `backup`: intentionally not combined with the idempotency
+# assertions in this file. `backup=yes`'s `changed` reflects whether the
+# backup file on the control node changed, not whether the device changed
+# (see DOCUMENTATION) -- this is pre-existing, shared netcommon action
+# plugin behavior, not specific to replace=config, and asserting on it here
+# would conflate two unrelated things.
+#
+# NOTE on teardown: this file captures the device's true pre-test SAVED
+# configuration *before* making any changes (original_config_capture below)
+# and restores exactly that via the same replace=config mechanism the rest
+# of the file exercises, inside an `always` block -- so a failure partway
+# through (not just a clean run) still leaves the device as it was found,
+# rather than at a hard-coded value that may differ from whatever was
+# actually there originally.
+#
+# NOTE on unsaved changes: the restore above is only correct if the device
+# had NO unsaved running-config changes before this test started -- this
+# file cannot safely capture or restore the live running config (see the
+# masked-secrets note below), only the saved state in config.boot. The
+# pre-test check immediately below fails fast rather than silently
+# discarding any pre-existing unsaved work.
+
+- name: check for pre-existing unsaved changes before this test begins
+ vyos.vyos.vyos_command:
+ commands:
+ - configure
+ - compare saved
+ - exit
+ register: pre_test_save_diff
+
+- name: fail fast if the device has unsaved changes before this test starts
+ ansible.builtin.assert:
+ that:
+ - >-
+ pre_test_save_diff.stdout[1] in
+ ['[edit]', 'No changes between working and saved configurations.\n\n[edit]']
+ fail_msg: >-
+ This device has unsaved configuration changes from before this test
+ started. Teardown restores from a capture of /config/config.boot (the
+ saved state) taken before setup, and cannot safely preserve unsaved
+ running-config changes that predate this test -- doing so would
+ silently discard them. Save or discard any pending changes on this
+ device before running this test.
+
+- name: capture original full config before any changes, for teardown restoration
+ vyos.vyos.vyos_command:
+ commands: "cat /config/config.boot"
+ register: original_config_capture
+
+- name: safety check -- fail fast if the device masked secrets in the original capture
+ ansible.builtin.assert:
+ that:
+ - "'****************' not in original_config_capture.stdout[0]"
+ fail_msg: >-
+ show configuration returned masked secret placeholders in this
+ capture. This is a known VyOS behavior when querying via automation
+ (see DOCUMENTATION note on replace=config); this test cannot safely
+ continue with a masked candidate. Re-run against a lab image/session
+ where show configuration returns real values, or adjust the baseline
+ capture method.
+
+- block:
+ - name: setup baseline marker value
+ vyos.vyos.vyos_config:
+ lines:
+ - set system option reboot-on-upgrade-failure '7'
+ match: none
+ # save=true is required here: commit alone updates the running config
+ # but not /config/config.boot, which our baseline capture below reads
+ # directly. Without an explicit save, config.boot can reflect a stale
+ # value from any earlier save in the device's history rather than what
+ # was just committed -- confirmed as the root cause of a real failure
+ # during development (a stale, unrelated value silently made it into
+ # the candidate instead of this task's freshly-committed one).
+ save: true
+
+ - name: capture full hierarchical baseline config from the device
+ # cat /config/config.boot directly, rather than `show configuration` --
+ # confirmed during development that `show configuration` returns masked
+ # placeholder values for local users' password hashes when queried via
+ # automation (vyos_command/network_cli), even though the same rendering
+ # shows real values when typed interactively. /config/config.boot is the
+ # same underlying file `show configuration` renders (confirmed identical
+ # structure during development), read directly rather than through
+ # VyOS's `show` masking layer.
+ vyos.vyos.vyos_command:
+ commands: "cat /config/config.boot"
+ register: baseline_show
+
+ # VyOS appears to mask local users' encrypted-password/plaintext-password
+ # values (a run of literal asterisks) specifically when show configuration
+ # is queried through automation (vyos_command/network_cli), even though the
+ # identical command returns the real hash when typed interactively at a
+ # terminal -- observed directly during development of this test. Pushing a
+ # masked capture back through replace=config sends the literal placeholder
+ # as the new password value and fails at commit (see DOCUMENTATION for the
+ # broader implication of this for replace=config generally). Fail fast here
+ # with a clear, actionable message rather than letting that surface as a
+ # confusing device-side "Invalid encrypted password" commit failure deep
+ # inside the actual test.
+ - name: safety check -- fail fast if the device masked secrets in this capture
+ ansible.builtin.assert:
+ that:
+ - "'****************' not in baseline_show.stdout[0]"
+ fail_msg: >-
+ show configuration returned masked secret placeholders in this
+ capture. This is a known VyOS behavior when querying via automation
+ (see DOCUMENTATION note on replace=config); this test cannot safely
+ continue with a masked candidate. Re-run against a lab image/session
+ where show configuration returns real values, or adjust the baseline
+ capture method.
+
+ - name: build edited candidate content (v1 -- change the marker value only)
+ ansible.builtin.set_fact:
+ edited_candidate_v1: >-
+ {{ baseline_show.stdout[0]
+ | replace('reboot-on-upgrade-failure "7"', 'reboot-on-upgrade-failure "12"') }}
+
+ # Fail fast here if the substitution above silently didn't match --
+ # config.boot quotes leaf values (reboot-on-upgrade-failure "7"), and a
+ # search string that misses that quoting produces a silent no-op edit
+ # rather than an error, which is a real bug caught during development:
+ # the candidate ends up byte-identical to the (possibly stale) capture,
+ # and any resulting diff reflects leftover device state rather than this
+ # test's intended edit.
+ - name: sanity check the marker edit actually took effect
+ ansible.builtin.assert:
+ that:
+ - ('reboot-on-upgrade-failure "12"' in edited_candidate_v1)
+ - ('reboot-on-upgrade-failure "7"' not in edited_candidate_v1)
+ fail_msg: >-
+ The marker substitution did not match anything in the captured
+ baseline -- edited_candidate_v1 is identical to the raw capture.
+ Check that the baseline actually contains
+ reboot-on-upgrade-failure "7" (quoted) and that the setup task's
+ save=true actually persisted before this capture ran.
+
+ - name: sanity check candidate still contains SSH/management essentials
+ ansible.builtin.assert:
+ that:
+ - "'service' in edited_candidate_v1"
+ - "'ssh' in edited_candidate_v1"
+ - "'login' in edited_candidate_v1"
+
+ - name: replace with edited full config (native load)
+ register: result
+ diff: true
+ vyos.vyos.vyos_config:
+ src: "{{ role_path }}/templates/replace_config_candidate.cfg"
+ replace: config
+ vars:
+ replace_config_candidate_content: "{{ edited_candidate_v1 }}"
+
+ - assert:
+ that:
+ - result.changed == true
+ - result.commands == ["load /tmp/ansible_vyos_replace.cfg"]
+ # replace=config surfaces VyOS's own compare() output verbatim in
+ # diff, not an itemized set/delete list -- commands is deliberately
+ # not the change content in this mode (see RETURN docs). Checking
+ # the specific new value (not just the field name) is deliberate --
+ # a weaker check here previously let a wrong-value regression through
+ # undetected for several tasks.
+ - ('reboot-on-upgrade-failure "12"' in (result.diff.prepared | default('')))
+
+ - name: verify connectivity survived
+ vyos.vyos.vyos_facts:
+ gather_subset: min
+ register: facts_check
+
+ - assert:
+ that:
+ - facts_check is succeeded
+
+ - name: re-run the identical replace (idempotency check, no backup involved)
+ register: result_repeat
+ vyos.vyos.vyos_config:
+ src: "{{ role_path }}/templates/replace_config_candidate.cfg"
+ replace: config
+ vars:
+ replace_config_candidate_content: "{{ edited_candidate_v1 }}"
+
+ - assert:
+ that:
+ - result_repeat.changed == false
+
+ - name: confirm replace=config rejects a candidate without src
+ register: no_src_result
+ ignore_errors: true
+ vyos.vyos.vyos_config:
+ replace: config
+
+ - assert:
+ that:
+ - no_src_result is failed
+ - "'src' in no_src_result.msg"
+
+ - name: confirm replace=config rejects lines+src together
+ register: lines_and_src_result
+ ignore_errors: true
+ vyos.vyos.vyos_config:
+ replace: config
+ src: "{{ role_path }}/templates/replace_config_candidate.cfg"
+ lines:
+ - "set system host-name foo"
+ vars:
+ replace_config_candidate_content: "{{ edited_candidate_v1 }}"
+
+ - assert:
+ that:
+ - lines_and_src_result is failed
+
+ - name: build edited candidate content (v2 -- further marker change, for check_mode)
+ ansible.builtin.set_fact:
+ edited_candidate_v2: >-
+ {{ edited_candidate_v1
+ | replace('reboot-on-upgrade-failure "12"', 'reboot-on-upgrade-failure "20"') }}
+
+ - name: sanity check the v2 marker edit actually took effect
+ ansible.builtin.assert:
+ that:
+ - ('reboot-on-upgrade-failure "20"' in edited_candidate_v2)
+ - ('reboot-on-upgrade-failure "12"' not in edited_candidate_v2)
+
+ - name: check_mode preview does not commit
+ check_mode: true
+ diff: true
+ register: check_result
+ vyos.vyos.vyos_config:
+ src: "{{ role_path }}/templates/replace_config_candidate.cfg"
+ replace: config
+ vars:
+ replace_config_candidate_content: "{{ edited_candidate_v2 }}"
+
+ - assert:
+ that:
+ - check_result.changed == true
+ - ('reboot-on-upgrade-failure "20"' in (check_result.diff.prepared | default('')))
+
+ - name: confirm check_mode preview above was not actually applied
+ vyos.vyos.vyos_command:
+ commands: "show configuration commands | match reboot-on-upgrade-failure"
+ register: post_check_value
+
+ - assert:
+ that:
+ - "'20' not in post_check_value.stdout[0]"
+ - "'12' in post_check_value.stdout[0]"
+
+ always:
+ - name: teardown -- restore the true original saved configuration captured before any changes
+ vyos.vyos.vyos_config:
+ src: "{{ role_path }}/templates/replace_config_candidate.cfg"
+ replace: config
+ save: true
+ vars:
+ replace_config_candidate_content: "{{ original_config_capture.stdout[0] }}"
+
+- debug: msg="END cli/replace_config.yaml on connection={{ ansible_connection }}"
diff --git a/tests/unit/modules/network/vyos/test_vyos_config.py b/tests/unit/modules/network/vyos/test_vyos_config.py
index e732ca60..d6a75f1d 100644
--- a/tests/unit/modules/network/vyos/test_vyos_config.py
+++ b/tests/unit/modules/network/vyos/test_vyos_config.py
@@ -1,4 +1,3 @@
-#
# (c) 2016 Red Hat Inc.
#
# This file is part of Ansible
@@ -19,6 +18,7 @@
# Make coding more python3-ish
from __future__ import absolute_import, division, print_function
+
__metaclass__ = type
from unittest.mock import MagicMock, patch
@@ -56,6 +56,11 @@ class TestVyosConfigModule(TestVyosModule):
)
self.get_connection = self.mock_get_connection.start()
+ self.mock_copy_file = patch(
+ "ansible_collections.vyos.vyos.plugins.modules.vyos_config.copy_file",
+ )
+ self.copy_file = self.mock_copy_file.start()
+
self.cliconf_obj = Cliconf(MagicMock())
self.running_config = load_fixture("vyos_config_config.cfg")
@@ -70,6 +75,7 @@ class TestVyosConfigModule(TestVyosModule):
self.mock_load_config.stop()
self.mock_run_commands.stop()
self.mock_get_connection.stop()
+ self.mock_copy_file.stop()
def load_fixtures(self, commands=None, filename=None):
config_file = "vyos_config_config.cfg"
@@ -177,3 +183,203 @@ class TestVyosConfigModule(TestVyosModule):
self.assertEqual(self.load_config.call_args[1]["confirm"], confirm_timeout)
self.run_commands.assert_not_called()
+
+ # -- replace=config (T6837, cisco.iosxr.iosxr_config replace=config analogue) --
+
+ def test_vyos_config_replace_config_requires_src(self):
+ """replace=config without src must fail argument validation, not run."""
+ set_module_args(dict(replace="config"))
+ result = self.execute_module(failed=True)
+ self.assertIn("src", result["msg"])
+
+ def test_vyos_config_replace_config_rejects_lines_only(self):
+ """replace=config with only lines (no src) must fail --
+ required_if demands src regardless of what else is set.
+ """
+ set_module_args(dict(replace="config", lines=["set system host-name foo"]))
+ self.execute_module(failed=True)
+
+ def test_vyos_config_replace_config_rejects_lines_and_src_together(self):
+ """lines/src remain mutually exclusive regardless of replace --
+ this is the pre-existing constraint, unaffected by replace=config."""
+ set_module_args(
+ dict(
+ replace="config",
+ src="system {\n host-name router\n}\n",
+ lines=["set system host-name foo"],
+ ),
+ )
+ self.execute_module(failed=True)
+
+ def test_vyos_config_replace_config_pushes_and_loads(self):
+ """replace=config with a real change: copies the candidate to a fixed
+ remote path, issues a single `load <path>` command, and reports the
+ device's own diff verbatim -- not an itemized set/delete list.
+ """
+ src = "interfaces {\n ethernet eth0 {\n address dhcp\n }\n}\n"
+ set_module_args(dict(replace="config", src=src))
+ self.load_config.side_effect = lambda *a, **kw: (
+ "[edit interfaces]\n+ethernet eth0 {\n+ address dhcp\n+}"
+ )
+
+ result = self.execute_module(changed=True)
+
+ self.assertEqual(result["commands"], ["load /tmp/ansible_vyos_replace.cfg"])
+ self.copy_file.assert_called_once()
+ # positional call: copy_file(module, local_path, remote_path, proto)
+ self.assertEqual(self.copy_file.call_args[0][2], "/tmp/ansible_vyos_replace.cfg")
+ self.assertEqual(self.copy_file.call_args[0][3], "scp")
+ self.assertEqual(
+ self.load_config.call_args[0][1],
+ ["load /tmp/ansible_vyos_replace.cfg"],
+ )
+
+ def test_vyos_config_replace_config_noop(self):
+ """replace=config with load_config() returning falsy (VyOS's own
+ `compare` reported no changes) must report changed=False, not
+ unconditionally True.
+ """
+ src = "system {\n host-name router\n}\n"
+ set_module_args(dict(replace="config", src=src))
+ self.load_config.return_value = None
+
+ result = self.execute_module(changed=False)
+ self.assertEqual(result["commands"], ["load /tmp/ansible_vyos_replace.cfg"])
+
+ def test_vyos_config_replace_config_check_mode(self):
+ """Under check_mode, commit=False must be passed through to
+ load_config() -- the candidate is still copied/loaded for an accurate
+ compare-based preview diff, but nothing is committed.
+ """
+ src = "system {\n host-name router\n}\n"
+ set_module_args(dict(replace="config", src=src, _ansible_check_mode=True))
+ self.load_config.side_effect = lambda *a, **kw: (
+ "[edit system]\n-host-name foo\n+host-name router"
+ )
+
+ self.execute_module(changed=True)
+
+ self.assertEqual(self.load_config.call_args[1]["commit"], False)
+
+ def test_vyos_config_replace_config_confirm_automatic(self):
+ src = "system {\n host-name router\n}\n"
+ confirm_timeout = 9
+ set_module_args(
+ dict(
+ replace="config",
+ src=src,
+ confirm="automatic",
+ confirm_timeout=confirm_timeout,
+ ),
+ )
+ self.load_config.side_effect = lambda *a, **kw: (
+ "[edit system]\n-host-name foo\n+host-name router"
+ )
+
+ self.execute_module(changed=True)
+
+ self.assertEqual(self.load_config.call_args[1]["confirm"], confirm_timeout)
+ self.run_commands.assert_called_once()
+ self.assertEqual(["configure", "confirm", "exit"], self.run_commands.call_args[0][1])
+
+ def test_vyos_config_replace_config_diff(self):
+ """With --diff, result['diff']['prepared'] must carry VyOS's own
+ compare() output verbatim -- not an itemized command list, since none
+ is computed in this mode.
+ """
+ src = "system {\n host-name router\n}\n"
+ set_module_args(dict(replace="config", src=src, _ansible_diff=True))
+ raw_compare = "[edit system]\n-host-name foo\n+host-name router"
+ self.load_config.side_effect = lambda *a, **kw: raw_compare
+
+ result = self.execute_module(changed=True)
+
+ self.assertEqual(result["diff"]["prepared"], raw_compare)
+
+ def test_vyos_config_replace_config_does_not_use_line_diff_path(self):
+ """replace=config must never call connection.get_diff() -- that path
+ (and match/allow_password_change) is specific to replace=line and is
+ documented as ignored under replace=config.
+ """
+ src = "system {\n host-name router\n}\n"
+ set_module_args(dict(replace="config", src=src, match="none"))
+ self.load_config.side_effect = lambda *a, **kw: (
+ "[edit system]\n-host-name foo\n+host-name router"
+ )
+
+ self.execute_module(changed=True)
+
+ self.conn.get_diff.assert_not_called()
+
+ def test_vyos_config_replace_config_confirm_automatic_check_mode_no_confirm_sent(self):
+ """Regression guard: confirm=automatic must not send the
+ configure/confirm/exit sequence under check_mode, even when a real
+ diff is present -- nothing was actually committed to confirm.
+ """
+ src = "system {\n host-name router\n}\n"
+ set_module_args(
+ dict(
+ replace="config",
+ src=src,
+ confirm="automatic",
+ _ansible_check_mode=True,
+ ),
+ )
+ self.load_config.side_effect = lambda *a, **kw: (
+ "[edit system]\n-host-name foo\n+host-name router"
+ )
+
+ self.execute_module(changed=True)
+
+ self.run_commands.assert_not_called()
+
+ def test_vyos_config_replace_config_confirm_automatic_noop_no_confirm_sent(self):
+ """Regression guard: confirm=automatic must not send the
+ configure/confirm/exit sequence when load_config() reports no diff
+ (VyOS's own compare() found nothing to commit) -- there is nothing
+ pending to confirm.
+ """
+ src = "system {\n host-name router\n}\n"
+ set_module_args(dict(replace="config", src=src, confirm="automatic"))
+ self.load_config.return_value = None
+
+ self.execute_module(changed=False)
+
+ self.run_commands.assert_not_called()
+
+ def test_vyos_config_replace_line_default_unaffected(self):
+ """Regression guard: default replace='line' must behave identically
+ to the pre-patch module -- copy_file() must never be invoked.
+ """
+ commands = ["set system host-name foo"]
+ set_module_args(dict(lines=commands))
+ candidate = "\n".join(commands)
+ self.conn.get_diff = MagicMock(
+ return_value=self.cliconf_obj.get_diff(candidate, self.running_config),
+ )
+ self.execute_module(changed=True, commands=commands)
+ self.copy_file.assert_not_called()
+
+ def test_sanitize_config_filters_password_delete_lines(self):
+ """
+ sanitize_config()/PASSWORD_NEEDLE must filter 'delete ... password'
+ lines the same way it filters 'set ... password' lines, since
+ replace=config can generate deletes for password config the
+ candidate omits.
+ """
+ result = {}
+ commands = [
+ "set system host-name foo",
+ "delete system login user admin authentication encrypted-password",
+ "set system login user admin authentication plaintext-password 'secret'",
+ ]
+ vyos_config.sanitize_config(commands, result, allow="none")
+ self.assertIn(
+ "delete system login user admin authentication encrypted-password",
+ result["filtered"],
+ )
+ self.assertIn(
+ "set system login user admin authentication plaintext-password 'secret'",
+ result["filtered"],
+ )
+ self.assertNotIn("set system host-name foo", result["filtered"])