diff options
| author | omnom62 <75066712+omnom62@users.noreply.github.com> | 2026-09-16 22:32:53 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-16 15:32:53 +0300 |
| commit | 762c276f61dd8fb599d9600df93c1a2992a8cf2e (patch) | |
| tree | 1e0d1c381f4dd2ac722661b150d11c0c44221d81 /tests | |
| parent | 25635b0560863eb4a5b34434bb34dfd7c43d77f9 (diff) | |
| download | vyos.vyos-762c276f61dd8fb599d9600df93c1a2992a8cf2e.tar.gz vyos.vyos-762c276f61dd8fb599d9600df93c1a2992a8cf2e.zip | |
* T6837: vyos_config replace core
Diffstat (limited to 'tests')
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"]) |
