diff --git a/changelogs/fragments/single_user_mode.yaml b/changelogs/fragments/single_user_mode.yaml
new file mode 100644
index 00000000..596b899b
--- /dev/null
+++ b/changelogs/fragments/single_user_mode.yaml
@@ -0,0 +1,4 @@
+---
+minor_changes:
+ - Add support for configuration caching (single_user_mode).
+ - Re-use device_info dictionary in cliconf.
diff --git a/plugins/cliconf/vyos.py b/plugins/cliconf/vyos.py
index de9e93d6..c8aaff9f 100644
--- a/plugins/cliconf/vyos.py
+++ b/plugins/cliconf/vyos.py
@@ -1,341 +1,360 @@
#
# (c) 2017 Red Hat Inc.
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see .
#
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = """
author: Ansible Networking Team
cliconf: vyos
short_description: Use vyos cliconf to run command on VyOS platform
description:
- This vyos plugin provides low level abstraction apis for sending and receiving CLI
commands from VyOS network devices.
version_added: 1.0.0
+options:
+ config_commands:
+ description:
+ - Specifies a list of commands that can make configuration changes
+ to the target device.
+ - When `ansible_network_single_user_mode` is enabled, if a command sent
+ to the device is present in this list, the existing cache is invalidated.
+ version_added: 2.0.0
+ type: list
+ default: []
+ vars:
+ - name: ansible_vyos_config_commands
"""
import re
import json
from ansible.errors import AnsibleConnectionFailure
from ansible.module_utils._text import to_text
from ansible.module_utils.common._collections_compat import Mapping
from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.config import (
NetworkConfig,
)
from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
to_list,
)
from ansible.plugins.cliconf import CliconfBase
class Cliconf(CliconfBase):
+ def __init__(self, *args, **kwargs):
+ super(Cliconf, self).__init__(*args, **kwargs)
+ self._device_info = {}
+
def get_device_info(self):
- device_info = {}
+ if not self._device_info:
+ device_info = {}
+
+ device_info["network_os"] = "vyos"
+ reply = self.get("show version")
+ data = to_text(reply, errors="surrogate_or_strict").strip()
- device_info["network_os"] = "vyos"
- reply = self.get("show version")
- data = to_text(reply, errors="surrogate_or_strict").strip()
+ match = re.search(r"Version:\s*(.*)", data)
+ if match:
+ device_info["network_os_version"] = match.group(1)
- match = re.search(r"Version:\s*(.*)", data)
- if match:
- device_info["network_os_version"] = match.group(1)
+ match = re.search(r"HW model:\s*(\S+)", data)
+ if match:
+ device_info["network_os_model"] = match.group(1)
- match = re.search(r"HW model:\s*(\S+)", data)
- if match:
- device_info["network_os_model"] = match.group(1)
+ reply = self.get("show host name")
+ device_info["network_os_hostname"] = to_text(
+ reply, errors="surrogate_or_strict"
+ ).strip()
- reply = self.get("show host name")
- device_info["network_os_hostname"] = to_text(
- reply, errors="surrogate_or_strict"
- ).strip()
+ self._device_info = device_info
- return device_info
+ return self._device_info
def get_config(self, flags=None, format=None):
if format:
option_values = self.get_option_values()
if format not in option_values["format"]:
raise ValueError(
"'format' value %s is invalid. Valid values of format are %s"
% (format, ", ".join(option_values["format"]))
)
if not flags:
flags = []
if format == "text":
command = "show configuration"
else:
command = "show configuration commands"
command += " ".join(to_list(flags))
command = command.strip()
out = self.send_command(command)
return out
def edit_config(
self, candidate=None, commit=True, replace=None, comment=None
):
resp = {}
operations = self.get_device_operations()
self.check_edit_config_capability(
operations, candidate, commit, replace, comment
)
results = []
requests = []
self.send_command("configure")
for cmd in to_list(candidate):
if not isinstance(cmd, Mapping):
cmd = {"command": cmd}
results.append(self.send_command(**cmd))
requests.append(cmd["command"])
out = self.get("compare")
out = to_text(out, errors="surrogate_or_strict")
diff_config = out if not out.startswith("No changes") else None
if diff_config:
if commit:
try:
self.commit(comment)
except AnsibleConnectionFailure as e:
msg = "commit failed: %s" % e.message
self.discard_changes()
raise AnsibleConnectionFailure(msg)
else:
self.send_command("exit")
else:
self.discard_changes()
else:
self.send_command("exit")
if (
to_text(
self._connection.get_prompt(), errors="surrogate_or_strict"
)
.strip()
.endswith("#")
):
self.discard_changes()
if diff_config:
resp["diff"] = diff_config
resp["response"] = results
resp["request"] = requests
return resp
def get(
self,
command=None,
prompt=None,
answer=None,
sendonly=False,
output=None,
newline=True,
check_all=False,
):
if not command:
raise ValueError("must provide value of command to execute")
if output:
raise ValueError(
"'output' value %s is not supported for get" % output
)
return self.send_command(
command=command,
prompt=prompt,
answer=answer,
sendonly=sendonly,
newline=newline,
check_all=check_all,
)
def commit(self, comment=None):
if comment:
command = 'commit comment "{0}"'.format(comment)
else:
command = "commit"
self.send_command(command)
def discard_changes(self):
self.send_command("exit discard")
def get_diff(
self,
candidate=None,
running=None,
diff_match="line",
diff_ignore_lines=None,
path=None,
diff_replace=None,
):
diff = {}
device_operations = self.get_device_operations()
option_values = self.get_option_values()
if candidate is None and device_operations["supports_generate_diff"]:
raise ValueError(
"candidate configuration is required to generate diff"
)
if diff_match not in option_values["diff_match"]:
raise ValueError(
"'match' value %s in invalid, valid values are %s"
% (diff_match, ", ".join(option_values["diff_match"]))
)
if diff_replace:
raise ValueError("'replace' in diff is not supported")
if diff_ignore_lines:
raise ValueError("'diff_ignore_lines' in diff is not supported")
if path:
raise ValueError("'path' in diff is not supported")
set_format = candidate.startswith("set") or candidate.startswith(
"delete"
)
candidate_obj = NetworkConfig(indent=4, contents=candidate)
if not set_format:
config = [c.line for c in candidate_obj.items]
commands = list()
# this filters out less specific lines
for item in config:
for index, entry in enumerate(commands):
if item.startswith(entry):
del commands[index]
break
commands.append(item)
candidate_commands = [
"set %s" % cmd.replace(" {", "") for cmd in commands
]
else:
candidate_commands = str(candidate).strip().split("\n")
if diff_match == "none":
diff["config_diff"] = list(candidate_commands)
return diff
running_commands = [
str(c).replace("'", "") for c in running.splitlines()
]
updates = list()
visited = set()
for line in candidate_commands:
item = str(line).replace("'", "")
if not item.startswith("set") and not item.startswith("delete"):
raise ValueError(
"line must start with either `set` or `delete`"
)
elif item.startswith("set") and item not in running_commands:
updates.append(line)
elif item.startswith("delete"):
if not running_commands:
updates.append(line)
else:
item = re.sub(r"delete", "set", item)
for entry in running_commands:
if entry.startswith(item) and line not in visited:
updates.append(line)
visited.add(line)
diff["config_diff"] = list(updates)
return diff
def run_commands(self, commands=None, check_rc=True):
if commands is None:
raise ValueError("'commands' value is required")
responses = list()
for cmd in to_list(commands):
if not isinstance(cmd, Mapping):
cmd = {"command": cmd}
output = cmd.pop("output", None)
if output:
raise ValueError(
"'output' value %s is not supported for run_commands"
% output
)
try:
out = self.send_command(**cmd)
except AnsibleConnectionFailure as e:
if check_rc:
raise
out = getattr(e, "err", e)
responses.append(out)
return responses
def get_device_operations(self):
return {
"supports_diff_replace": False,
"supports_commit": True,
"supports_rollback": False,
"supports_defaults": False,
"supports_onbox_diff": True,
"supports_commit_comment": True,
"supports_multiline_delimiter": False,
"supports_diff_match": True,
"supports_diff_ignore_lines": False,
"supports_generate_diff": False,
"supports_replace": False,
}
def get_option_values(self):
return {
"format": ["text", "set"],
"diff_match": ["line", "none"],
"diff_replace": [],
"output": [],
}
def get_capabilities(self):
result = super(Cliconf, self).get_capabilities()
result["rpc"] += [
"commit",
"discard_changes",
"get_diff",
"run_commands",
]
result["device_operations"] = self.get_device_operations()
result.update(self.get_option_values())
return json.dumps(result)
def set_cli_prompt_context(self):
"""
Make sure we are in the operational cli mode
:return: None
"""
if self._connection.connected:
self._update_cli_prompt_context(
config_context="#", exit_command="exit discard"
)
diff --git a/plugins/terminal/vyos.py b/plugins/terminal/vyos.py
index a6590022..bf9d87b2 100644
--- a/plugins/terminal/vyos.py
+++ b/plugins/terminal/vyos.py
@@ -1,73 +1,75 @@
#
# (c) 2016 Red Hat Inc.
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see .
#
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
import re
from ansible.plugins.terminal import TerminalBase
from ansible.errors import AnsibleConnectionFailure
class TerminalModule(TerminalBase):
terminal_stdout_re = [
re.compile(br"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"),
re.compile(br"\@[\w\-\.]+:\S+?[>#\$] ?$"),
]
terminal_stderr_re = [
re.compile(br"\n\s*Invalid command:"),
re.compile(br"\nCommit failed"),
re.compile(br"\n\s+Set failed"),
]
ansi_re = TerminalBase.ansi_re + [
# Color codes
re.compile(br"\x1b\[(\d+(;\d+)*)?m"),
# Clear line (CSI K)
re.compile(br"\x1b\[K"),
# Xterm change cursor mode (CSI ? 1 [h|l])
re.compile(br"\x1b\[\?1(h|l)"),
# Xterm change keypad (ESC [=|>])
re.compile(br"\x1b(=|>)"),
# Xterm window title string (OSC
BEL)
re.compile(br"\x1b]0;[^\x07]*\x07"),
]
+ terminal_config_prompt = re.compile(r"^.+#$")
+
try:
terminal_length = os.getenv("ANSIBLE_VYOS_TERMINAL_LENGTH", 10000)
terminal_length = int(terminal_length)
except ValueError:
raise AnsibleConnectionFailure(
"Invalid value set for vyos terminal length '%s', value should be a valid integer string"
% terminal_length
)
def on_open_shell(self):
try:
for cmd in (b"set terminal length 0", b"set terminal width 512"):
self._exec_cli_command(cmd)
self._exec_cli_command(
b"set terminal length %d" % self.terminal_length
)
except AnsibleConnectionFailure:
raise AnsibleConnectionFailure("unable to set terminal parameters")
diff --git a/tests/integration/targets/vyos_smoke/aliases b/tests/integration/targets/vyos_smoke/aliases
new file mode 100644
index 00000000..8071e1f7
--- /dev/null
+++ b/tests/integration/targets/vyos_smoke/aliases
@@ -0,0 +1 @@
+shippable/vyos/group1
diff --git a/tests/integration/targets/vyos_smoke/defaults/main.yaml b/tests/integration/targets/vyos_smoke/defaults/main.yaml
new file mode 100644
index 00000000..a845c24b
--- /dev/null
+++ b/tests/integration/targets/vyos_smoke/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: '*'
+test_items: []
diff --git a/tests/integration/targets/vyos_smoke/tasks/cli.yaml b/tests/integration/targets/vyos_smoke/tasks/cli.yaml
new file mode 100644
index 00000000..f5808166
--- /dev/null
+++ b/tests/integration/targets/vyos_smoke/tasks/cli.yaml
@@ -0,0 +1,18 @@
+---
+- name: collect all cli test cases
+ find:
+ paths: '{{ role_path }}/tests/cli'
+ patterns: '{{ testcase }}.yaml'
+ register: test_cases
+ delegate_to: localhost
+
+- name: set test_items
+ set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}"
+
+- name: run test case with single_user_mode (connection=network_cli)
+ include: '{{ test_case_to_run }} ansible_connection=network_cli ansible_network_single_user_mode=True'
+ with_items: '{{ test_items }}'
+ loop_control:
+ loop_var: test_case_to_run
+ tags:
+ - network_cli
diff --git a/tests/integration/targets/vyos_smoke/tasks/main.yaml b/tests/integration/targets/vyos_smoke/tasks/main.yaml
new file mode 100644
index 00000000..07b0f2e9
--- /dev/null
+++ b/tests/integration/targets/vyos_smoke/tasks/main.yaml
@@ -0,0 +1,2 @@
+---
+- include: cli.yaml
diff --git a/tests/integration/targets/vyos_smoke/tests/cli/caching.yaml b/tests/integration/targets/vyos_smoke/tests/cli/caching.yaml
new file mode 100644
index 00000000..4230bfa5
--- /dev/null
+++ b/tests/integration/targets/vyos_smoke/tests/cli/caching.yaml
@@ -0,0 +1,85 @@
+---
+- block:
+ - debug: msg="START connection={{ ansible_connection }} cli/caching.yaml"
+
+ - set_fact:
+ interface_cmds:
+ - set interfaces ethernet eth1 description 'Configured by Ansible - Interface 1'
+ - set interfaces ethernet eth1 mtu '1500'
+ - set interfaces ethernet eth1 duplex 'auto'
+ - set interfaces ethernet eth1 speed 'auto'
+ - set interfaces ethernet eth1 vif 101 description 'Eth1 - VIF 101'
+ - set interfaces ethernet eth2 description 'Configured by Ansible - Interface 2 (ADMIN DOWN)'
+ - set interfaces ethernet eth2 mtu '600'
+ l3_interface_cmds:
+ - set interfaces ethernet eth1 address '192.0.2.10/24'
+ - set interfaces ethernet eth1 address '2001:db8::10/32'
+ - set interfaces ethernet eth2 address '198.51.100.10/24'
+
+ - name: Remove interfaces from config before actual testing
+ ignore_errors: true
+ vyos.vyos.vyos_config: &rem
+ lines:
+ - delete interfaces ethernet eth1
+ - delete interfaces ethernet eth2
+ match: none
+
+ - name: Merge base interfaces configuration
+ register: result
+ vyos.vyos.vyos_interfaces: &merged
+ config:
+ - name: eth1
+ description: Configured by Ansible - Interface 1
+ mtu: 1500
+ speed: auto
+ duplex: auto
+ vifs:
+ - vlan_id: 101
+ description: Eth1 - VIF 101
+
+ - name: eth2
+ description: Configured by Ansible - Interface 2 (ADMIN DOWN)
+ mtu: 600
+ state: merged
+
+ - assert:
+ that:
+ - "{{ interface_cmds | symmetric_difference(result['commands']) |length == 0 }}"
+
+ - name: Merge base interfaces configuration (IDEMPOTENT)
+ register: result
+ vyos.vyos.vyos_interfaces: *merged
+
+ - assert:
+ that:
+ - result.changed == False
+
+ - name: Merge L3 interfaces configuration
+ register: result
+ vyos.vyos.vyos_l3_interfaces: &mergedl3
+ config:
+ - name: eth1
+ ipv4:
+ - address: 192.0.2.10/24
+ ipv6:
+ - address: 2001:db8::10/32
+ - name: eth2
+ ipv4:
+ - address: 198.51.100.10/24
+ state: merged
+
+ - assert:
+ that:
+ - "{{ l3_interface_cmds | symmetric_difference(result['commands']) |length == 0 }}"
+
+ - name: Merge L3 interfaces configuration (IDEMPOTENT)
+ register: result
+ vyos.vyos.vyos_l3_interfaces: *mergedl3
+
+ - assert:
+ that:
+ - result.changed == False
+ always:
+ - name: cleanup
+ vyos.vyos.vyos_config: *rem
+ when: ansible_connection == "network_cli" and ansible_network_single_user_mode|d(False)