Skip to content

Commit 2ec47a5

Browse files
authored
[generic-config-updater] Handling empty tables while sorting a patch (sonic-net#1923)
#### What I did Fixing issue sonic-net#1908 #### How I did it - When the given patch produces empty-table, reject it since ConfigDb cannot show empty tables. Achieved that by: - Adding a validation step before patch-sorting - The patch-sorter should not produce steps which result in empty-tables because it is not possible in ConfigDb, we should instead delete the whole table. Achieved that by: - Adding a new validator to reject moves producing empty tables - No need to add a generator for deleting the whole table, current generators take care of that. They do by the following: - The move to empty a table is rejected by `NoEmptyTableMoveValidator` - The previous rejected move is used to generate moves using `UpperLevelMoveExtender` which produces either - Remove move for parent -- This is good, we are removing the table - Replace move for parent -- This later on will be extended to a Delete move using `DeleteInsteadOfReplaceMoveExtender` The generators/validators are documented in the `PatchSorter.py`, check the documentation out. #### How to verify it unit-test #### Previous command output (if the output of a command-line utility has changed) ```sh admin@vlab-01:~$ sudo config apply-patch empty-a-table.json-patch -v Patch Applier: Patch application starting. Patch Applier: Patch: [{"op": "replace", "path": "/DEVICE_METADATA", "value": {}}] Patch Applier: Validating patch is not making changes to tables without YANG models. Patch Applier: Getting current config db. Patch Applier: Simulating the target full config after applying the patch. Patch Applier: Validating target config according to YANG models. ... sonig-yang-mgmt logs Patch Applier: Sorting patch updates. ... sonig-yang-mgmt logs Patch Applier: The patch was sorted into 11 changes: Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/bgp_asn"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/buffer_model"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/default_bgp_status"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/default_pfcwd_status"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/deployment_id"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/docker_routing_config_mode"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/hostname"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/hwsku"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/mac"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost/platform"}] Patch Applier: * [{"op": "remove", "path": "/DEVICE_METADATA/localhost"}] Patch Applier: Applying changes in order. Patch Applier: Verifying patch updates are reflected on ConfigDB. Failed to apply patch Usage: config apply-patch [OPTIONS] PATCH_FILE_PATH Try "config apply-patch -h" for help. Error: After applying patch to config, there are still some parts not updated admin@vlab-01:~$ ``` The above error occurs because post the update, the whole `DEVICE_METADATA` table is deleted, not just showing as empty i.e. `"DEVICE_METADATA": {}` #### New command output (if the output of a command-line utility has changed) ``` admin@vlab-01:~$ sudo config apply-patch empty-a-table.json-patch -v Patch Applier: Patch application starting. Patch Applier: Patch: [{"op": "replace", "path": "/DEVICE_METADATA", "value": {}}] Patch Applier: Validating patch is not making changes to tables without YANG models. Patch Applier: Getting current config db. Patch Applier: Simulating the target full config after applying the patch. Patch Applier: Validating target config does not have empty tables, since they do not show up in ConfigDb. Failed to apply patch Usage: config apply-patch [OPTIONS] PATCH_FILE_PATH Try "config apply-patch -h" for help. Error: Given patch is not valid because it will result in empty tables which is not allowed in ConfigDb. Table: DEVICE_METADATA admin@vlab-01:~$ ```
1 parent fdedcbf commit 2ec47a5

File tree

7 files changed

+244
-5
lines changed

7 files changed

+244
-5
lines changed

generic_config_updater/generic_updater.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ def apply(self, patch):
5252
self.logger.log_notice("Simulating the target full config after applying the patch.")
5353
target_config = self.patch_wrapper.simulate_patch(patch, old_config)
5454

55-
# Validate target config
55+
# Validate target config does not have empty tables since they do not show up in ConfigDb
56+
self.logger.log_notice("Validating target config does not have empty tables, " \
57+
"since they do not show up in ConfigDb.")
58+
empty_tables = self.config_wrapper.get_empty_tables(target_config)
59+
if empty_tables: # if there are empty tables
60+
empty_tables_txt = ", ".join(empty_tables)
61+
raise ValueError("Given patch is not valid because it will result in empty tables " \
62+
"which is not allowed in ConfigDb. " \
63+
f"Table{'s' if len(empty_tables) != 1 else ''}: {empty_tables_txt}")
64+
65+
# Validate target config according to YANG models
5666
self.logger.log_notice("Validating target config according to YANG models.")
5767
if not(self.config_wrapper.validate_config_db_config(target_config)):
5868
raise ValueError(f"Given patch is not valid because it will result in an invalid config")

generic_config_updater/gu_common.py

+8
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ def crop_tables_without_yang(self, config_db_as_json):
133133
sy._cropConfigDB()
134134

135135
return sy.jIn
136+
137+
def get_empty_tables(self, config):
138+
empty_tables = []
139+
for key in config.keys():
140+
if not(config[key]):
141+
empty_tables.append(key)
142+
return empty_tables
143+
136144

137145
class DryRunConfigWrapper(ConfigWrapper):
138146
# TODO: implement DryRunConfigWrapper

generic_config_updater/patch_sorter.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,35 @@ def _find_ref_paths(self, paths, config):
573573
refs.extend(self.path_addressing.find_ref_paths(path, config))
574574
return refs
575575

576+
class NoEmptyTableMoveValidator:
577+
"""
578+
A class to validate that a move will not result in an empty table, because empty table do not show up in ConfigDB.
579+
"""
580+
def __init__(self, path_addressing):
581+
self.path_addressing = path_addressing
582+
583+
def validate(self, move, diff):
584+
simulated_config = move.apply(diff.current_config)
585+
op_path = move.path
586+
587+
if op_path == "": # If updating whole file
588+
tables_to_check = simulated_config.keys()
589+
else:
590+
tokens = self.path_addressing.get_path_tokens(op_path)
591+
tables_to_check = [tokens[0]]
592+
593+
return self._validate_tables(tables_to_check, simulated_config)
594+
595+
def _validate_tables(self, tables, config):
596+
for table in tables:
597+
if not(self._validate_table(table, config)):
598+
return False
599+
return True
600+
601+
def _validate_table(self, table, config):
602+
# the only invalid case is if table exists and is empty
603+
return table not in config or config[table]
604+
576605
class LowLevelMoveGenerator:
577606
"""
578607
A class to generate the low level moves i.e. moves corresponding to differences between current/target config
@@ -969,7 +998,8 @@ def create(self, algorithm=Algorithm.DFS):
969998
FullConfigMoveValidator(self.config_wrapper),
970999
NoDependencyMoveValidator(self.path_addressing, self.config_wrapper),
9711000
UniqueLanesMoveValidator(),
972-
CreateOnlyMoveValidator(self.path_addressing) ]
1001+
CreateOnlyMoveValidator(self.path_addressing),
1002+
NoEmptyTableMoveValidator(self.path_addressing)]
9731003

9741004
move_wrapper = MoveWrapper(move_generators, move_extenders, move_validators)
9751005

tests/generic_config_updater/files/config_db_with_interface.json

+11-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"Ethernet8|10.0.0.1/30": {
55
"family": "IPv4",
66
"scope": "global"
7-
}
7+
},
8+
"Ethernet9": {}
89
},
910
"PORT": {
1011
"Ethernet8": {
@@ -15,6 +16,15 @@
1516
"lanes": "65",
1617
"mtu": "9000",
1718
"speed": "25000"
19+
},
20+
"Ethernet9": {
21+
"admin_status": "up",
22+
"alias": "eth9",
23+
"description": "Ethernet9",
24+
"fec": "rs",
25+
"lanes": "6",
26+
"mtu": "9000",
27+
"speed": "25000"
1828
}
1929
}
2030
}

tests/generic_config_updater/generic_updater_test.py

+11
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ def test_apply__invalid_patch_updating_tables_without_yang_models__failure(self)
1919
# Act and assert
2020
self.assertRaises(ValueError, patch_applier.apply, Files.MULTI_OPERATION_CONFIG_DB_PATCH)
2121

22+
def test_apply__invalid_patch_producing_empty_tables__failure(self):
23+
# Arrange
24+
patch_applier = self.__create_patch_applier(valid_patch_does_not_produce_empty_tables=False)
25+
26+
# Act and assert
27+
self.assertRaises(ValueError, patch_applier.apply, Files.MULTI_OPERATION_CONFIG_DB_PATCH)
28+
2229
def test_apply__invalid_config_db__failure(self):
2330
# Arrange
2431
patch_applier = self.__create_patch_applier(valid_config_db=False)
@@ -58,12 +65,16 @@ def __create_patch_applier(self,
5865
changes=None,
5966
valid_patch_only_tables_with_yang_models=True,
6067
valid_config_db=True,
68+
valid_patch_does_not_produce_empty_tables=True,
6169
verified_same_config=True):
6270
config_wrapper = Mock()
6371
config_wrapper.get_config_db_as_json.side_effect = \
6472
[Files.CONFIG_DB_AS_JSON, Files.CONFIG_DB_AFTER_MULTI_PATCH]
6573
config_wrapper.validate_config_db_config.side_effect = \
6674
create_side_effect_dict({(str(Files.CONFIG_DB_AFTER_MULTI_PATCH),): valid_config_db})
75+
empty_tables = [] if valid_patch_does_not_produce_empty_tables else ["AnyTable"]
76+
config_wrapper.get_empty_tables.side_effect = \
77+
create_side_effect_dict({(str(Files.CONFIG_DB_AFTER_MULTI_PATCH),): empty_tables})
6778

6879
patch_wrapper = Mock()
6980
patch_wrapper.validate_config_db_patch_has_yang_models.side_effect = \

tests/generic_config_updater/gu_common_test.py

+33
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,39 @@ def test_crop_tables_without_yang__returns_cropped_config_db_as_json(self):
148148
# Assert
149149
self.assertDictEqual(expected, actual)
150150

151+
def test_get_empty_tables__no_empty_tables__returns_no_tables(self):
152+
# Arrange
153+
config_wrapper = gu_common.ConfigWrapper()
154+
config = {"any_table": {"key": "value"}}
155+
156+
# Act
157+
empty_tables = config_wrapper.get_empty_tables(config)
158+
159+
# Assert
160+
self.assertCountEqual([], empty_tables)
161+
162+
def test_get_empty_tables__single_empty_table__returns_one_table(self):
163+
# Arrange
164+
config_wrapper = gu_common.ConfigWrapper()
165+
config = {"any_table": {"key": "value"}, "another_table":{}}
166+
167+
# Act
168+
empty_tables = config_wrapper.get_empty_tables(config)
169+
170+
# Assert
171+
self.assertCountEqual(["another_table"], empty_tables)
172+
173+
def test_get_empty_tables__multiple_empty_tables__returns_multiple_tables(self):
174+
# Arrange
175+
config_wrapper = gu_common.ConfigWrapper()
176+
config = {"any_table": {"key": "value"}, "another_table":{}, "yet_another_table":{}}
177+
178+
# Act
179+
empty_tables = config_wrapper.get_empty_tables(config)
180+
181+
# Assert
182+
self.assertCountEqual(["another_table", "yet_another_table"], empty_tables)
183+
151184
class TestPatchWrapper(unittest.TestCase):
152185
def setUp(self):
153186
self.config_wrapper_mock = gu_common.ConfigWrapper()

tests/generic_config_updater/patch_sorter_test.py

+139-2
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,121 @@ def test_validate__replace_whole_config_item_same_ref_same__true(self):
10081008
def prepare_config(self, config, patch):
10091009
return patch.apply(config)
10101010

1011+
class TestNoEmptyTableMoveValidator(unittest.TestCase):
1012+
def setUp(self):
1013+
path_addressing = ps.PathAddressing()
1014+
self.validator = ps.NoEmptyTableMoveValidator(path_addressing)
1015+
1016+
def test_validate__no_changes__success(self):
1017+
# Arrange
1018+
current_config = {"some_table":{"key1":"value1", "key2":"value2"}}
1019+
target_config = {"some_table":{"key1":"value1", "key2":"value22"}}
1020+
diff = ps.Diff(current_config, target_config)
1021+
move = ps.JsonMove(diff, OperationType.REPLACE, ["some_table", "key1"], ["some_table", "key1"])
1022+
1023+
# Act and assert
1024+
self.assertTrue(self.validator.validate(move, diff))
1025+
1026+
def test_validate__change_but_no_empty_table__success(self):
1027+
# Arrange
1028+
current_config = {"some_table":{"key1":"value1", "key2":"value2"}}
1029+
target_config = {"some_table":{"key1":"value1", "key2":"value22"}}
1030+
diff = ps.Diff(current_config, target_config)
1031+
move = ps.JsonMove(diff, OperationType.REPLACE, ["some_table", "key2"], ["some_table", "key2"])
1032+
1033+
# Act and assert
1034+
self.assertTrue(self.validator.validate(move, diff))
1035+
1036+
def test_validate__single_empty_table__failure(self):
1037+
# Arrange
1038+
current_config = {"some_table":{"key1":"value1", "key2":"value2"}}
1039+
target_config = {"some_table":{}}
1040+
diff = ps.Diff(current_config, target_config)
1041+
move = ps.JsonMove(diff, OperationType.REPLACE, ["some_table"], ["some_table"])
1042+
1043+
# Act and assert
1044+
self.assertFalse(self.validator.validate(move, diff))
1045+
1046+
def test_validate__whole_config_replace_single_empty_table__failure(self):
1047+
# Arrange
1048+
current_config = {"some_table":{"key1":"value1", "key2":"value2"}}
1049+
target_config = {"some_table":{}}
1050+
diff = ps.Diff(current_config, target_config)
1051+
move = ps.JsonMove(diff, OperationType.REPLACE, [], [])
1052+
1053+
# Act and assert
1054+
self.assertFalse(self.validator.validate(move, diff))
1055+
1056+
def test_validate__whole_config_replace_mix_of_empty_and_non_empty__failure(self):
1057+
# Arrange
1058+
current_config = {"some_table":{"key1":"value1"}, "other_table":{"key2":"value2"}}
1059+
target_config = {"some_table":{"key1":"value1"}, "other_table":{}}
1060+
diff = ps.Diff(current_config, target_config)
1061+
move = ps.JsonMove(diff, OperationType.REPLACE, [], [])
1062+
1063+
# Act and assert
1064+
self.assertFalse(self.validator.validate(move, diff))
1065+
1066+
def test_validate__whole_config_multiple_empty_tables__failure(self):
1067+
# Arrange
1068+
current_config = {"some_table":{"key1":"value1"}, "other_table":{"key2":"value2"}}
1069+
target_config = {"some_table":{}, "other_table":{}}
1070+
diff = ps.Diff(current_config, target_config)
1071+
move = ps.JsonMove(diff, OperationType.REPLACE, [], [])
1072+
1073+
# Act and assert
1074+
self.assertFalse(self.validator.validate(move, diff))
1075+
1076+
def test_validate__remove_key_empties_a_table__failure(self):
1077+
# Arrange
1078+
current_config = {"some_table":{"key1":"value1"}, "other_table":{"key2":"value2"}}
1079+
target_config = {"some_table":{"key1":"value1"}, "other_table":{}}
1080+
diff = ps.Diff(current_config, target_config)
1081+
move = ps.JsonMove(diff, OperationType.REMOVE, ["other_table", "key2"], [])
1082+
1083+
# Act and assert
1084+
self.assertFalse(self.validator.validate(move, diff))
1085+
1086+
def test_validate__remove_key_but_table_has_other_keys__success(self):
1087+
# Arrange
1088+
current_config = {"some_table":{"key1":"value1"}, "other_table":{"key2":"value2", "key3":"value3"}}
1089+
target_config = {"some_table":{"key1":"value1"}, "other_table":{"key3":"value3"}}
1090+
diff = ps.Diff(current_config, target_config)
1091+
move = ps.JsonMove(diff, OperationType.REMOVE, ["other_table", "key2"], [])
1092+
1093+
# Act and assert
1094+
self.assertTrue(self.validator.validate(move, diff))
1095+
1096+
def test_validate__remove_whole_table__success(self):
1097+
# Arrange
1098+
current_config = {"some_table":{"key1":"value1"}, "other_table":{"key2":"value2"}}
1099+
target_config = {"some_table":{"key1":"value1"}}
1100+
diff = ps.Diff(current_config, target_config)
1101+
move = ps.JsonMove(diff, OperationType.REMOVE, ["other_table"], [])
1102+
1103+
# Act and assert
1104+
self.assertTrue(self.validator.validate(move, diff))
1105+
1106+
def test_validate__add_empty_table__failure(self):
1107+
# Arrange
1108+
current_config = {"some_table":{"key1":"value1"}, "other_table":{"key2":"value2"}}
1109+
target_config = {"new_table":{}}
1110+
diff = ps.Diff(current_config, target_config)
1111+
move = ps.JsonMove(diff, OperationType.ADD, ["new_table"], ["new_table"])
1112+
1113+
# Act and assert
1114+
self.assertFalse(self.validator.validate(move, diff))
1115+
1116+
def test_validate__add_non_empty_table__success(self):
1117+
# Arrange
1118+
current_config = {"some_table":{"key1":"value1"}, "other_table":{"key2":"value2"}}
1119+
target_config = {"new_table":{"key3":"value3"}}
1120+
diff = ps.Diff(current_config, target_config)
1121+
move = ps.JsonMove(diff, OperationType.ADD, ["new_table"], ["new_table"])
1122+
1123+
# Act and assert
1124+
self.assertTrue(self.validator.validate(move, diff))
1125+
10111126
class TestLowLevelMoveGenerator(unittest.TestCase):
10121127
def setUp(self):
10131128
path_addressing = PathAddressing()
@@ -1566,7 +1681,8 @@ def verify(self, algo, algo_class):
15661681
ps.FullConfigMoveValidator,
15671682
ps.NoDependencyMoveValidator,
15681683
ps.UniqueLanesMoveValidator,
1569-
ps.CreateOnlyMoveValidator]
1684+
ps.CreateOnlyMoveValidator,
1685+
ps.NoEmptyTableMoveValidator]
15701686

15711687
# Act
15721688
sorter = factory.create(algo)
@@ -1712,13 +1828,34 @@ def test_sort__modify_items_with_dependencies_using_must__success(self):
17121828
{"op":"replace", "path":"/CRM/Config/acl_counter_low_threshold", "value":"60"}],
17131829
cc_ops=[{"op":"replace", "path":"", "value":Files.CONFIG_DB_WITH_CRM}])
17141830

1831+
def test_sort__modify_items_result_in_empty_table__failure(self):
1832+
# Emptying a table
1833+
self.assertRaises(GenericConfigUpdaterError,
1834+
self.verify,
1835+
cc_ops=[{"op":"replace", "path":"", "value":Files.SIMPLE_CONFIG_DB_INC_DEPS}],
1836+
tc_ops=[{"op":"replace", "path":"", "value":Files.SIMPLE_CONFIG_DB_INC_DEPS},
1837+
{"op":"replace", "path":"/ACL_TABLE", "value":{}}])
1838+
# Adding an empty table
1839+
self.assertRaises(GenericConfigUpdaterError,
1840+
self.verify,
1841+
cc_ops=[{"op":"replace", "path":"", "value":Files.ANY_CONFIG_DB}],
1842+
tc_ops=[{"op":"replace", "path":"", "value":Files.ANY_CONFIG_DB},
1843+
{"op":"add", "path":"/VLAN", "value":{}}])
1844+
# Emptying multiple tables
1845+
self.assertRaises(GenericConfigUpdaterError,
1846+
self.verify,
1847+
cc_ops=[{"op":"replace", "path":"", "value":Files.SIMPLE_CONFIG_DB_INC_DEPS}],
1848+
tc_ops=[{"op":"replace", "path":"", "value":Files.SIMPLE_CONFIG_DB_INC_DEPS},
1849+
{"op":"replace", "path":"/ACL_TABLE", "value":{}},
1850+
{"op":"replace", "path":"/PORT", "value":{}}])
1851+
17151852
def verify(self, cc_ops=[], tc_ops=[]):
17161853
# Arrange
17171854
config_wrapper=ConfigWrapper()
17181855
target_config=jsonpatch.JsonPatch(tc_ops).apply(Files.CROPPED_CONFIG_DB_AS_JSON)
17191856
current_config=jsonpatch.JsonPatch(cc_ops).apply(Files.CROPPED_CONFIG_DB_AS_JSON)
17201857
patch=jsonpatch.make_patch(current_config, target_config)
1721-
1858+
17221859
# Act
17231860
actual = self.create_patch_sorter(current_config).sort(patch)
17241861

0 commit comments

Comments
 (0)