diff --git a/config/plugins/sonic-dhcpv4-relay_yang.py b/config/plugins/sonic-dhcpv4-relay_yang.py new file mode 100644 index 0000000000..b6276e2fa1 --- /dev/null +++ b/config/plugins/sonic-dhcpv4-relay_yang.py @@ -0,0 +1,473 @@ +""" +Autogenerated config CLI plugin. + + +""" + +import copy +import click +import utilities_common.cli as clicommon +import utilities_common.general as general +from config import config_mgmt + + +# Load sonic-cfggen from source since /usr/local/bin/sonic-cfggen does not have .py extension. +sonic_cfggen = general.load_module_from_source('sonic_cfggen', '/usr/local/bin/sonic-cfggen') + + +def exit_with_error(*args, **kwargs): + """ Print a message with click.secho and abort CLI. + + Args: + args: Positional arguments to pass to click.secho + kwargs: Keyword arguments to pass to click.secho + """ + + click.secho(*args, **kwargs) + raise click.Abort() + + +def validate_config_or_raise(cfg): + """ Validate config db data using ConfigMgmt. + + Args: + cfg (Dict): Config DB data to validate. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + try: + cfg = sonic_cfggen.FormatConverter.to_serialized(copy.deepcopy(cfg)) + config_mgmt.ConfigMgmt().loadData(cfg) + except Exception as err: + raise Exception('Failed to validate configuration: {}'.format(err)) + + +def add_entry_validated(db, table, key, data): + """ Add new entry in table and validate configuration. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to add new entry to. + key (Union[str, Tuple]): Key name in the table. + data (Dict): Entry data. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + if key in cfg[table]: + raise Exception(f"{key} already exists") + + cfg[table][key] = data + + validate_config_or_raise(cfg) + db.set_entry(table, key, data) + + +def update_entry_validated(db, table, key, data, create_if_not_exists=False): + """ Update entry in table and validate configuration. + If attribute value in data is None, the attribute is deleted. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to add new entry to. + key (Union[str, Tuple]): Key name in the table. + data (Dict): Entry data. + create_if_not_exists (bool): + In case entry does not exists already a new entry + is not created if this flag is set to False and + creates a new entry if flag is set to True. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + + if not data: + raise Exception(f"No field/values to update {key}") + + if create_if_not_exists: + cfg[table].setdefault(key, {}) + + if key not in cfg[table]: + raise Exception(f"{key} does not exist") + + entry_changed = False + for attr, value in data.items(): + if value == cfg[table][key].get(attr): + continue + entry_changed = True + if value is None: + cfg[table][key].pop(attr, None) + else: + cfg[table][key][attr] = value + + if not entry_changed: + return + + validate_config_or_raise(cfg) + db.set_entry(table, key, cfg[table][key]) + + +def del_entry_validated(db, table, key): + """ Delete entry in table and validate configuration. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to add new entry to. + key (Union[str, Tuple]): Key name in the table. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + if key not in cfg[table]: + raise Exception(f"{key} does not exist") + + cfg[table].pop(key) + + validate_config_or_raise(cfg) + db.set_entry(table, key, None) + + +def add_list_entry_validated(db, table, key, attr, data): + """ Add new entry into list in table and validate configuration. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to add data to. + key (Union[str, Tuple]): Key name in the table. + attr (str): Attribute name which represents a list the data needs to be added to. + data (List): Data list to add to config DB. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + if key not in cfg[table]: + raise Exception(f"{key} does not exist") + cfg[table][key].setdefault(attr, []) + for entry in data: + if entry in cfg[table][key][attr]: + raise Exception(f"{entry} already exists") + cfg[table][key][attr].append(entry) + + validate_config_or_raise(cfg) + db.set_entry(table, key, cfg[table][key]) + + +def del_list_entry_validated(db, table, key, attr, data): + """ Delete entry from list in table and validate configuration. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to remove data from. + key (Union[str, Tuple]): Key name in the table. + attr (str): Attribute name which represents a list the data needs to be removed from. + data (Dict): Data list to remove from config DB. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + if key not in cfg[table]: + raise Exception(f"{key} does not exist") + cfg[table][key].setdefault(attr, []) + for entry in data: + if entry not in cfg[table][key][attr]: + raise Exception(f"{entry} does not exist") + cfg[table][key][attr].remove(entry) + if not cfg[table][key][attr]: + cfg[table][key].pop(attr) + + validate_config_or_raise(cfg) + db.set_entry(table, key, cfg[table][key]) + + +def clear_list_entry_validated(db, table, key, attr): + """ Clear list in object and validate configuration. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to remove the list attribute from. + key (Union[str, Tuple]): Key name in the table. + attr (str): Attribute name which represents a list that needs to be removed. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + update_entry_validated(db, table, key, {attr: None}) + + +@click.group(name="dhcpv4-relay", + cls=clicommon.AliasedGroup) +def DHCPV4_RELAY(): + """ DHCPV4_RELAY part of config_db.json """ + + pass + + +@DHCPV4_RELAY.command(name="add") +@click.argument( + "name", + nargs=1, + required=True, +) +@click.option( + "--server-vrf", + help="Server VRF", +) +@click.option( + "--source-interface", + help="Used to determine the source IP address of the relayed packet", +) +@click.option( + "--link-selection", + help="Enable link selection", +) +@click.option( + "--vrf-selection", + help="Enable VRF selection", +) +@click.option( + "--server-id-override", + help="Enable server id override", +) +@click.option( + "--agent-relay-mode", + help="How to forward packets that already have a relay option", +) +@click.option( + "--max-hop-count", + help="Maximum hop count for relayed packets", +) +@click.option( + "--dhcpv4-servers", + help="Server IPv4 address list", +) +@clicommon.pass_db +def DHCPV4_RELAY_add(db, name, server_vrf, source_interface, link_selection, vrf_selection, + server_id_override, agent_relay_mode, max_hop_count, dhcpv4_servers): + """ Add object in DHCPV4_RELAY. """ + + table = "DHCPV4_RELAY" + key = name + data = {} + if server_vrf is not None: + data["server_vrf"] = server_vrf + if source_interface is not None: + data["source_interface"] = source_interface + if link_selection is not None: + data["link_selection"] = link_selection + if vrf_selection is not None: + data["vrf_selection"] = vrf_selection + if server_id_override is not None: + data["server_id_override"] = server_id_override + if agent_relay_mode is not None: + data["agent_relay_mode"] = agent_relay_mode + if max_hop_count is not None: + data["max_hop_count"] = max_hop_count + if dhcpv4_servers is not None: + data["dhcpv4_servers"] = dhcpv4_servers.split(",") + + try: + add_entry_validated(db.cfgdb, table, key, data) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@DHCPV4_RELAY.command(name="update") +@click.argument( + "name", + nargs=1, + required=True, +) +@click.option( + "--server-vrf", + help="Server VRF", +) +@click.option( + "--source-interface", + help="Used to determine the source IP address of the relayed packet", +) +@click.option( + "--link-selection", + help="Enable link selection", +) +@click.option( + "--vrf-selection", + help="Enable VRF selection", +) +@click.option( + "--server-id-override", + help="Enable server id override", +) +@click.option( + "--agent-relay-mode", + help="How to forward packets that already have a relay option", +) +@click.option( + "--max-hop-count", + help="Maximum hop count for relayed packets", +) +@click.option( + "--dhcpv4-servers", + help="Server IPv4 address list", +) +@clicommon.pass_db +def DHCPV4_RELAY_update(db, name, server_vrf, source_interface, link_selection, vrf_selection, + server_id_override, agent_relay_mode, max_hop_count, dhcpv4_servers): + """ Add object in DHCPV4_RELAY. """ + + table = "DHCPV4_RELAY" + key = name + data = {} + if server_vrf is not None: + data["server_vrf"] = server_vrf + if source_interface is not None: + data["source_interface"] = source_interface + if link_selection is not None: + data["link_selection"] = link_selection + if vrf_selection is not None: + data["vrf_selection"] = vrf_selection + if server_id_override is not None: + data["server_id_override"] = server_id_override + if agent_relay_mode is not None: + data["agent_relay_mode"] = agent_relay_mode + if max_hop_count is not None: + data["max_hop_count"] = max_hop_count + if dhcpv4_servers is not None: + data["dhcpv4_servers"] = dhcpv4_servers.split(",") + + try: + update_entry_validated(db.cfgdb, table, key, data) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@DHCPV4_RELAY.command(name="delete") +@click.argument( + "name", + nargs=1, + required=True, +) +@clicommon.pass_db +def DHCPV4_RELAY_delete(db, name): + """ Delete object in DHCPV4_RELAY. """ + + table = "DHCPV4_RELAY" + key = name + try: + del_entry_validated(db.cfgdb, table, key) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@DHCPV4_RELAY.group(name="dhcpv4-servers", + cls=clicommon.AliasedGroup) +def DHCPV4_RELAY_dhcpv4_servers(): + """ Add/Delete dhcpv4_servers in DHCPV4_RELAY """ + + pass + + +@DHCPV4_RELAY_dhcpv4_servers.command(name="add") +@click.argument( + "name", + nargs=1, + required=True, +) +@click.argument( + "dhcpv4-servers", + nargs=-1, + required=True, +) +@clicommon.pass_db +def DHCPV4_RELAY_dhcpv4_servers_add( + db, + name, dhcpv4_servers +): + """ Add dhcpv4_servers in DHCPV4_RELAY """ + + table = "DHCPV4_RELAY" + key = name + attr = "dhcpv4_servers" + data = dhcpv4_servers + + try: + add_list_entry_validated(db.cfgdb, table, key, attr, data) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@DHCPV4_RELAY_dhcpv4_servers.command(name="delete") +@click.argument( + "name", + nargs=1, + required=True, +) +@click.argument( + "dhcpv4-servers", + nargs=-1, + required=True, +) +@clicommon.pass_db +def DHCPV4_RELAY_dhcpv4_servers_delete( + db, + name, dhcpv4_servers +): + """ Delete dhcpv4_servers in DHCPV4_RELAY """ + + table = "DHCPV4_RELAY" + key = name + attr = "dhcpv4_servers" + data = dhcpv4_servers + + try: + del_list_entry_validated(db.cfgdb, table, key, attr, data) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@DHCPV4_RELAY_dhcpv4_servers.command(name="clear") +@click.argument( + "name", + nargs=1, + required=True, +) +@clicommon.pass_db +def DHCPV4_RELAY_dhcpv4_servers_clear( + db, + name +): + """ Clear dhcpv4_servers in DHCPV4_RELAY """ + + table = "DHCPV4_RELAY" + key = name + attr = "dhcpv4_servers" + + try: + clear_list_entry_validated(db.cfgdb, table, key, attr) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +def register(cli): + """ Register new CLI nodes in root CLI. + + Args: + cli: Root CLI node. + Raises: + Exception: when root CLI already has a command + we are trying to register. + """ + cli_node = DHCPV4_RELAY + if cli_node.name in cli.commands: + raise Exception(f"{cli_node.name} already exists in CLI") + cli.add_command(DHCPV4_RELAY) diff --git a/show/plugins/sonic-dhcpv4-relay_yang.py b/show/plugins/sonic-dhcpv4-relay_yang.py new file mode 100644 index 0000000000..54ce939d32 --- /dev/null +++ b/show/plugins/sonic-dhcpv4-relay_yang.py @@ -0,0 +1,132 @@ +""" +Auto-generated show CLI plugin. +""" + +import click +import tabulate +import natsort +import utilities_common.cli as clicommon + + +def format_attr_value(entry, attr): + """ Helper that formats attribute to be presented in the table output. + + Args: + entry (Dict[str, str]): CONFIG DB entry configuration. + attr (Dict): Attribute metadata. + + Returns: + str: fomatted attribute value. + """ + + if attr["is-leaf-list"]: + return "\n".join(entry.get(attr["name"], [])) + return entry.get(attr["name"], "N/A") + + +def format_group_value(entry, attrs): + """ Helper that formats grouped attribute to be presented in the table output. + + Args: + entry (Dict[str, str]): CONFIG DB entry configuration. + attrs (List[Dict]): Attributes metadata that belongs to the same group. + + Returns: + str: fomatted group attributes. + """ + + data = [] + for attr in attrs: + if entry.get(attr["name"]): + data.append((attr["name"] + ":", format_attr_value(entry, attr))) + return tabulate.tabulate(data, tablefmt="plain") + + +@click.group(name="dhcpv4-relay", + cls=clicommon.AliasedGroup, + invoke_without_command=True) +@clicommon.pass_db +def DHCPV4_RELAY(db): + """ [Callable command group] """ + + header = [ + "NAME", + "SERVER VRF", + "SOURCE INTERFACE", + "LINK SELECTION", + "VRF SELECTION", + "SERVER ID OVERRIDE", + "AGENT RELAY MODE", + "MAX HOP COUNT", + "DHCPV4 SERVERS", + ] + + body = [] + + table = db.cfgdb.get_table("DHCPV4_RELAY") + for key in natsort.natsorted(table): + entry = table[key] + if not isinstance(key, tuple): + key = (key,) + + row = [*key] + [ + format_attr_value( + entry, + {'name': 'server_vrf', 'description': 'Server VRF', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'source_interface', 'description': 'Used to determine the source IP address of the \ + relayed packet', 'is-leaf-list': False, 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'link_selection', 'description': 'Enable link selection', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'vrf_selection', 'description': 'Enable VRF selection', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'server_id_override', 'description': 'Enable server id override', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'agent_relay_mode', 'description': 'How to forward packets that already have a relay option', + 'is-leaf-list': False, 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'max_hop_count', 'description': 'Maximum hop count for relayed packets', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'dhcpv4_servers', 'description': 'Server IPv4 address list', 'is-leaf-list': True, + 'is-mandatory': False, 'group': ''} + ), + ] + + body.append(row) + + click.echo(tabulate.tabulate(body, header)) + + +def register(cli): + """ Register new CLI nodes in root CLI. + + Args: + cli (click.core.Command): Root CLI node. + Raises: + Exception: when root CLI already has a command + we are trying to register. + """ + cli_node = DHCPV4_RELAY + if cli_node.name in cli.commands: + raise Exception(f"{cli_node.name} already exists in CLI") + cli.add_command(DHCPV4_RELAY) diff --git a/tests/dhcpv4_relay_input/assert_show_output.py b/tests/dhcpv4_relay_input/assert_show_output.py new file mode 100644 index 0000000000..6c794a9251 --- /dev/null +++ b/tests/dhcpv4_relay_input/assert_show_output.py @@ -0,0 +1,21 @@ +""" +Module holding the correct values for show CLI command outputs for the dhcpv4_relay_test.py +""" + +show_dhcpv4_relay_add = """\ +NAME SERVER VRF SOURCE INTERFACE LINK SELECTION VRF SELECTION SERVER ID OVERRIDE AGENT RELAY MODE MAX HOP COUNT DHCPV4 SERVERS +------ ------------ ------------------ ---------------- --------------- -------------------- ------------------ --------------- ---------------- +Vlan11 N/A N/A N/A N/A N/A N/A N/A 192.168.11.12 +""" + +show_dhcpv4_relay_update = """\ +NAME SERVER VRF SOURCE INTERFACE LINK SELECTION VRF SELECTION SERVER ID OVERRIDE AGENT RELAY MODE MAX HOP COUNT DHCPV4 SERVERS +------ ------------ ------------------ ---------------- --------------- -------------------- ------------------ --------------- ---------------- +Vlan11 N/A N/A N/A N/A N/A N/A N/A 192.168.11.13 +""" + +show_dhcpv4_relay_update_max_hop_count = """\ +NAME SERVER VRF SOURCE INTERFACE LINK SELECTION VRF SELECTION SERVER ID OVERRIDE AGENT RELAY MODE MAX HOP COUNT DHCPV4 SERVERS +------ ------------ ------------------ ---------------- --------------- -------------------- ------------------ --------------- ---------------- +Vlan11 N/A N/A N/A N/A N/A N/A 5 192.168.11.13 +""" diff --git a/tests/dhcpv4_relay_input/empty_config_db.json b/tests/dhcpv4_relay_input/empty_config_db.json new file mode 100644 index 0000000000..0db3279e44 --- /dev/null +++ b/tests/dhcpv4_relay_input/empty_config_db.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/tests/dhcpv4_relay_test.py b/tests/dhcpv4_relay_test.py new file mode 100644 index 0000000000..75bb3db24d --- /dev/null +++ b/tests/dhcpv4_relay_test.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python + +import os +import logging +import show.main as show +import config.main as config + +from .dhcpv4_relay_input import assert_show_output +from utilities_common.db import Db +from click.testing import CliRunner +from .mock_tables import dbconnector + +logger = logging.getLogger(__name__) +test_path = os.path.dirname(os.path.abspath(__file__)) +mock_db_path = os.path.join(test_path, "dhcpv4_relay_input") + +SUCCESS = 0 +ERROR = 1 +ERROR2 = 2 +INVALID_VALUE = 'INVALID' + +class TestDhcpv4Relay: + + @classmethod + def setup_class(cls): + logger.info("SETUP") + os.environ['UTILITIES_UNIT_TESTING'] = "2" + + @classmethod + def teardown_class(cls): + os.environ['UTILITIES_UNIT_TESTING'] = "0" + os.environ["UTILITIES_UNIT_TESTING_TOPOLOGY"] = "" + dbconnector.dedicated_dbs['CONFIG_DB'] = None + + def verify_output(self, output): + runner = CliRunner() + result = runner.invoke(show.cli.commands["dhcpv4-relay"], []) + + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == SUCCESS + if result.output != output: + print (f"Expected output:\n",output) + print (f"Actual output:\n", result.output) + assert False + assert result.output == output + + def test_dhcpv4_relay_add_delete(self): + dbconnector.dedicated_dbs['CONFIG_DB'] = os.path.join(mock_db_path, 'empty_config_db') + db = Db() + runner = CliRunner() + + # Add DHCPv4 relay with mandatory parameters + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["add"], + ["Vlan11", "--dhcpv4-servers", "192.168.11.12"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == SUCCESS + self.verify_output(assert_show_output.show_dhcpv4_relay_add) + + # Add DHCPv4 relay for the same VLAN again + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["add"], + ["Vlan11", "--dhcpv4-servers", "192.168.11.12"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == ERROR + + # try to add a DHCPv4 relay with missing mandatory parameters + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["add"], + ["Vlan12"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == ERROR2 + + # try to add a DHCPv4 relay with invalid ipv4 address + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["add"], + ["Vlan11", "--dhcpv4-servers", "192.168.11.256"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == ERROR + + # try to add a DHCPv4 relay with ipv6 address + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["add"], + ["Vlan11", "--dhcpv4-servers", "2001:db8::1"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == ERROR + + # update server IP for the existing Vlan11 configuration + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["update"], + ["Vlan11", "--dhcpv4-servers", "192.168.11.13"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == SUCCESS + self.verify_output(db, runner, assert_show_output.show_dhcpv4_relay_update) + + # update the DHCPv4 relay configuration with a valid max_hop_count + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["update"], + ["Vlan11", "--max-hop-count", "5"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == SUCCESS + self.verify_output(db, runner, "dhcpv4-relay", assert_show_output.show_dhcpv4_relay_update_max_hop_count) + + # update the DHCPv4 relay configuration with an invalid max_hop_count + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["update"], + ["Vlan11", "--max-hop-count", "abrakadabra"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == ERROR + + # update the DHCPv4 relay configuration with a out of range valid max_hop_count + result = runner.invoke( + config.config.commands["dhcpv4-relay"].commands["update"], + ["Vlan11", "--max-hop-count", "32"], obj=Db + ) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == ERROR