Skip to content

Commit b15f73a

Browse files
xincunli-sonicnmoray
authored andcommitted
Enhance list-checkpoints CLI (sonic-net#3746)
* Add datetime to list checkpoints * fix format * remove unused import * fix import * fix CHECKPOINTS_DIR * fix getmtime mock * Add a parameter to include modify time. * Fix UT * Fix test * Fix UT
1 parent deda670 commit b15f73a

File tree

4 files changed

+183
-23
lines changed

4 files changed

+183
-23
lines changed

config/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,12 +1795,14 @@ def delete_checkpoint(ctx, checkpoint_name, verbose):
17951795
ctx.fail(ex)
17961796

17971797
@config.command('list-checkpoints')
1798+
@click.option('-t', '--time', is_flag=True, default=False,
1799+
help='Add extra last modified time information for each checkpoint')
17981800
@click.option('-v', '--verbose', is_flag=True, default=False, help='print additional details of what the operation is doing')
17991801
@click.pass_context
1800-
def list_checkpoints(ctx, verbose):
1802+
def list_checkpoints(ctx, time, verbose):
18011803
"""List the config checkpoints available."""
18021804
try:
1803-
checkpoints_list = GenericUpdater().list_checkpoints(verbose)
1805+
checkpoints_list = GenericUpdater().list_checkpoints(time, verbose)
18041806
formatted_output = json.dumps(checkpoints_list, indent=4)
18051807
click.echo(formatted_output)
18061808
except Exception as ex:

generic_config_updater/generic_updater.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import subprocess
55

6+
from datetime import datetime, timezone
67
from enum import Enum
78
from .gu_common import HOST_NAMESPACE, GenericConfigUpdaterError, EmptyTableError, ConfigWrapper, \
89
DryRunConfigWrapper, PatchWrapper, genericUpdaterLogging
@@ -233,7 +234,7 @@ def checkpoint(self, checkpoint_name):
233234

234235
self.logger.log_notice("Config checkpoint completed.")
235236

236-
def list_checkpoints(self):
237+
def list_checkpoints(self, includes_time=False):
237238
self.logger.log_info("Listing checkpoints starting.")
238239

239240
self.logger.log_info(f"Verifying checkpoints directory '{self.checkpoints_dir}' exists.")
@@ -244,14 +245,35 @@ def list_checkpoints(self):
244245
self.logger.log_info("Getting checkpoints in checkpoints directory.")
245246
checkpoint_names = self.util.get_checkpoint_names()
246247

247-
checkpoints_len = len(checkpoint_names)
248-
self.logger.log_info(f"Found {checkpoints_len} checkpoint{'s' if checkpoints_len != 1 else ''}{':' if checkpoints_len > 0 else '.'}")
249-
for checkpoint_name in checkpoint_names:
250-
self.logger.log_info(f" * {checkpoint_name}")
248+
checkpoints = []
249+
if includes_time:
250+
for checkpoint_name in checkpoint_names:
251+
checkpoint_path = os.path.join(self.checkpoints_dir, checkpoint_name + CHECKPOINT_EXT)
252+
last_modified = datetime.fromtimestamp(os.path.getmtime(checkpoint_path), tz=timezone.utc).isoformat()
253+
checkpoints.append({"name": checkpoint_name, "time": last_modified})
254+
255+
checkpoints.sort(key=lambda x: x["time"], reverse=True)
256+
else:
257+
checkpoints = checkpoint_names
258+
259+
checkpoints_len = len(checkpoints)
260+
self.logger.log_info(
261+
"Found {} checkpoint{}{}".format(
262+
checkpoints_len,
263+
's' if checkpoints_len != 1 else '',
264+
':' if checkpoints_len > 0 else '.'
265+
)
266+
)
267+
268+
for checkpoint in checkpoints:
269+
if includes_time:
270+
self.logger.log_info(f" * {checkpoint['name']} (Last Modified: {checkpoint['time']})")
271+
else:
272+
self.logger.log_info(f" * {checkpoint}")
251273

252274
self.logger.log_info("Listing checkpoints completed.")
253275

254-
return checkpoint_names
276+
return checkpoints
255277

256278
def delete_checkpoint(self, checkpoint_name):
257279
self.logger.log_notice("Deleting checkpoint starting.")
@@ -407,8 +429,8 @@ def rollback(self, checkpoint_name):
407429
def checkpoint(self, checkpoint_name):
408430
self.decorated_config_rollbacker.checkpoint(checkpoint_name)
409431

410-
def list_checkpoints(self):
411-
return self.decorated_config_rollbacker.list_checkpoints()
432+
def list_checkpoints(self, includes_time):
433+
return self.decorated_config_rollbacker.list_checkpoints(includes_time)
412434

413435
def delete_checkpoint(self, checkpoint_name):
414436
self.decorated_config_rollbacker.delete_checkpoint(checkpoint_name)
@@ -617,6 +639,6 @@ def delete_checkpoint(self, checkpoint_name, verbose):
617639
config_rollbacker = self.generic_update_factory.create_config_rollbacker(verbose)
618640
config_rollbacker.delete_checkpoint(checkpoint_name)
619641

620-
def list_checkpoints(self, verbose):
642+
def list_checkpoints(self, includes_time, verbose):
621643
config_rollbacker = self.generic_update_factory.create_config_rollbacker(verbose)
622-
return config_rollbacker.list_checkpoints()
644+
return config_rollbacker.list_checkpoints(includes_time)

tests/config_test.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
import sys
1111
import unittest
1212
import ipaddress
13-
import shutil
13+
14+
from datetime import timezone
1415
from unittest import mock
1516
from jsonpatch import JsonPatchConflict
1617

@@ -1890,7 +1891,14 @@ def setUp(self):
18901891
self.any_target_config_as_text = json.dumps(self.any_target_config)
18911892
self.any_checkpoint_name = "any_checkpoint_name"
18921893
self.any_checkpoints_list = ["checkpoint1", "checkpoint2", "checkpoint3"]
1894+
self.any_checkpoints_list_with_time = [
1895+
{"name": "checkpoint1", "time": datetime.datetime.now(timezone.utc).isoformat()},
1896+
{"name": "checkpoint2", "time": datetime.datetime.now(timezone.utc).isoformat()},
1897+
{"name": "checkpoint3", "time": datetime.datetime.now(timezone.utc).isoformat()}
1898+
]
18931899
self.any_checkpoints_list_as_text = json.dumps(self.any_checkpoints_list, indent=4)
1900+
self.any_checkpoints_list_with_time_as_text = json.dumps(self.any_checkpoints_list_with_time, indent=4)
1901+
18941902

18951903
@patch('config.main.validate_patch', mock.Mock(return_value=True))
18961904
def test_apply_patch__no_params__get_required_params_error_msg(self):
@@ -2476,15 +2484,15 @@ def test_list_checkpoints__help__gets_help_msg(self):
24762484
def test_list_checkpoints__all_optional_params_non_default__non_default_values_used(self):
24772485
# Arrange
24782486
expected_exit_code = 0
2479-
expected_output = self.any_checkpoints_list_as_text
2480-
expected_call_with_non_default_values = mock.call(True)
2487+
expected_output = self.any_checkpoints_list_with_time_as_text
2488+
expected_call_with_non_default_values = mock.call(True, True)
24812489
mock_generic_updater = mock.Mock()
2482-
mock_generic_updater.list_checkpoints.return_value = self.any_checkpoints_list
2490+
mock_generic_updater.list_checkpoints.return_value = self.any_checkpoints_list_with_time
24832491
with mock.patch('config.main.GenericUpdater', return_value=mock_generic_updater):
24842492

24852493
# Act
24862494
result = self.runner.invoke(config.config.commands["list-checkpoints"],
2487-
["--verbose"],
2495+
["--time", "--verbose"],
24882496
catch_exceptions=False)
24892497

24902498
# Assert
@@ -2493,6 +2501,26 @@ def test_list_checkpoints__all_optional_params_non_default__non_default_values_u
24932501
mock_generic_updater.list_checkpoints.assert_called_once()
24942502
mock_generic_updater.list_checkpoints.assert_has_calls([expected_call_with_non_default_values])
24952503

2504+
def test_list_checkpoints__time_param_true__time_included_in_output(self):
2505+
# Arrange
2506+
expected_exit_code = 0
2507+
expected_output = self.any_checkpoints_list_with_time_as_text
2508+
expected_call_with_time_param = mock.call(True, False)
2509+
mock_generic_updater = mock.Mock()
2510+
mock_generic_updater.list_checkpoints.return_value = self.any_checkpoints_list_with_time
2511+
with mock.patch('config.main.GenericUpdater', return_value=mock_generic_updater):
2512+
2513+
# Act
2514+
result = self.runner.invoke(config.config.commands["list-checkpoints"],
2515+
["--time"],
2516+
catch_exceptions=False)
2517+
2518+
# Assert
2519+
self.assertEqual(expected_exit_code, result.exit_code)
2520+
self.assertTrue(expected_output in result.output)
2521+
mock_generic_updater.list_checkpoints.assert_called_once()
2522+
mock_generic_updater.list_checkpoints.assert_has_calls([expected_call_with_time_param])
2523+
24962524
def test_list_checkpoints__exception_thrown__error_displayed_error_code_returned(self):
24972525
# Arrange
24982526
unexpected_exit_code = 0
@@ -2512,7 +2540,7 @@ def test_list_checkpoints__exception_thrown__error_displayed_error_code_returned
25122540
def test_list_checkpoints__optional_parameters_passed_correctly(self):
25132541
self.validate_list_checkpoints_optional_parameter(
25142542
["--verbose"],
2515-
mock.call(True))
2543+
mock.call(False, True))
25162544

25172545
def validate_list_checkpoints_optional_parameter(self, param_args, expected_call):
25182546
# Arrange
@@ -4138,6 +4166,7 @@ def test_rollback_multiasic(self, mock_get_checkpoint_content):
41384166
self.assertEqual(result.exit_code, 0, "Command should succeed")
41394167
self.assertIn("Config rolled back successfully.", result.output)
41404168

4169+
@patch('os.path.getmtime', mock.Mock(return_value=1700000000.0))
41414170
@patch('generic_config_updater.generic_updater.Util.checkpoints_dir_exist', mock.Mock(return_value=True))
41424171
@patch('generic_config_updater.generic_updater.Util.get_checkpoint_names',
41434172
mock.Mock(return_value=["checkpointname"]))

tests/generic_config_updater/generic_updater_test.py

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import os
33
import shutil
44
import unittest
5+
6+
from datetime import datetime, timezone
57
from unittest.mock import MagicMock, Mock, call, patch
68
from .gutest_helpers import create_side_effect_dict, Files
79

@@ -206,7 +208,7 @@ def test_list_checkpoints__checkpoints_dir_exist_but_no_files__empty_list(self):
206208
# 'assertCountEqual' does check same count, same elements ignoring order
207209
self.assertCountEqual(expected, actual)
208210

209-
def test_list_checkpoints__checkpoints_dir_has_multiple_files__multiple_files(self):
211+
def test_list_checkpoints__checkpoints_dir_has_multiple_files(self):
210212
# Arrange
211213
self.create_checkpoints_dir()
212214
self.add_checkpoint(self.any_checkpoint_name, self.any_config)
@@ -221,7 +223,25 @@ def test_list_checkpoints__checkpoints_dir_has_multiple_files__multiple_files(se
221223
# 'assertCountEqual' does check same count, same elements ignoring order
222224
self.assertCountEqual(expected, actual)
223225

224-
def test_list_checkpoints__checkpoints_names_have_special_characters__multiple_files(self):
226+
def test_list_checkpoints__checkpoints_dir_has_multiple_files_with_time(self):
227+
# Arrange
228+
self.create_checkpoints_dir()
229+
mod_time1 = self.add_checkpoint(self.any_checkpoint_name, self.any_config)
230+
mod_time2 = self.add_checkpoint(self.any_other_checkpoint_name, self.any_config)
231+
rollbacker = self.create_rollbacker()
232+
expected = [
233+
{"name": self.any_checkpoint_name, "time": mod_time1},
234+
{"name": self.any_other_checkpoint_name, "time": mod_time2}
235+
]
236+
237+
# Act
238+
actual = rollbacker.list_checkpoints(includes_time=True)
239+
240+
# Assert
241+
# 'assertCountEqual' does check same count, same elements ignoring order
242+
self.assertCountEqual(expected, actual)
243+
244+
def test_list_checkpoints__checkpoints_names_have_special_characters_multiple_files(self):
225245
# Arrange
226246
self.create_checkpoints_dir()
227247
self.add_checkpoint("check.point1", self.any_config)
@@ -237,6 +257,26 @@ def test_list_checkpoints__checkpoints_names_have_special_characters__multiple_f
237257
# 'assertCountEqual' does check same count, same elements ignoring order
238258
self.assertCountEqual(expected, actual)
239259

260+
def test_list_checkpoints__checkpoints_names_have_special_characters_multiple_files_with_time(self):
261+
# Arrange
262+
self.create_checkpoints_dir()
263+
mod_time1 = self.add_checkpoint("check.point1", self.any_config)
264+
mod_time2 = self.add_checkpoint(".checkpoint2", self.any_config)
265+
mod_time3 = self.add_checkpoint("checkpoint3.", self.any_config)
266+
rollbacker = self.create_rollbacker()
267+
expected = [
268+
{"name": "check.point1", "time": mod_time1},
269+
{"name": ".checkpoint2", "time": mod_time2},
270+
{"name": "checkpoint3.", "time": mod_time3}
271+
]
272+
273+
# Act
274+
actual = rollbacker.list_checkpoints(includes_time=True)
275+
276+
# Assert
277+
# 'assertCountEqual' does check same count, same elements ignoring order
278+
self.assertCountEqual(expected, actual)
279+
240280
def test_delete_checkpoint__checkpoint_does_not_exist__failure(self):
241281
# Arrange
242282
rollbacker = self.create_rollbacker()
@@ -279,18 +319,60 @@ def test_multiple_operations(self):
279319
rollbacker.delete_checkpoint(self.any_other_checkpoint_name)
280320
self.assertCountEqual([], rollbacker.list_checkpoints())
281321

322+
def test_multiple_operations_with_time(self):
323+
rollbacker = self.create_rollbacker()
324+
325+
# 'assertCountEqual' does check same count, same elements ignoring order
326+
self.assertCountEqual([], rollbacker.list_checkpoints(includes_time=True))
327+
328+
rollbacker.checkpoint(self.any_checkpoint_name)
329+
self.assertCountEqual(
330+
[self.any_checkpoint_name],
331+
self.extract_checkpoint_names(rollbacker.list_checkpoints(includes_time=True))
332+
)
333+
self.assertEqual(self.any_config, self.get_checkpoint(self.any_checkpoint_name))
334+
335+
rollbacker.rollback(self.any_checkpoint_name)
336+
rollbacker.config_replacer.replace.assert_has_calls([call(self.any_config)])
337+
338+
rollbacker.checkpoint(self.any_other_checkpoint_name)
339+
self.assertCountEqual(
340+
[self.any_checkpoint_name, self.any_other_checkpoint_name],
341+
self.extract_checkpoint_names(rollbacker.list_checkpoints(includes_time=True))
342+
)
343+
self.assertEqual(self.any_config, self.get_checkpoint(self.any_other_checkpoint_name))
344+
345+
rollbacker.delete_checkpoint(self.any_checkpoint_name)
346+
self.assertCountEqual(
347+
[self.any_other_checkpoint_name],
348+
self.extract_checkpoint_names(rollbacker.list_checkpoints(includes_time=True))
349+
)
350+
351+
rollbacker.delete_checkpoint(self.any_other_checkpoint_name)
352+
self.assertCountEqual([], rollbacker.list_checkpoints(includes_time=True))
353+
354+
def extract_checkpoint_names(self, checkpoint_list):
355+
"""Extract checkpoint names from the list of dictionaries."""
356+
return [checkpoint["name"] for checkpoint in checkpoint_list]
357+
282358
def clean_up(self):
283359
if os.path.isdir(self.checkpoints_dir):
284360
shutil.rmtree(self.checkpoints_dir)
285361

286362
def create_checkpoints_dir(self):
287363
os.makedirs(self.checkpoints_dir)
288364

289-
def add_checkpoint(self, name, json_content):
365+
def add_checkpoint(self, name, json_content, mod_time=None):
290366
path=os.path.join(self.checkpoints_dir, f"{name}{self.checkpoint_ext}")
291367
with open(path, "w") as fh:
292368
fh.write(json.dumps(json_content))
293369

370+
if mod_time is None:
371+
mod_time = datetime.now(timezone.utc).timestamp()
372+
os.utime(path, (mod_time, mod_time))
373+
374+
return datetime.fromtimestamp(mod_time, tz=timezone.utc).isoformat()
375+
294376
def get_checkpoint(self, name):
295377
path=os.path.join(self.checkpoints_dir, f"{name}{self.checkpoint_ext}")
296378
with open(path) as fh:
@@ -519,8 +601,14 @@ def setUp(self):
519601
self.any_checkpoint_name = "anycheckpoint"
520602
self.any_other_checkpoint_name = "anyothercheckpoint"
521603
self.any_checkpoints_list = [self.any_checkpoint_name, self.any_other_checkpoint_name]
604+
self.any_checkpoints_list_with_time = [
605+
{"name": self.any_checkpoint_name, "time": datetime.now(timezone.utc).isoformat()},
606+
{"name": self.any_other_checkpoint_name, "time": datetime.now(timezone.utc).isoformat()}
607+
]
608+
522609
self.any_config_format = gu.ConfigFormat.SONICYANG
523610
self.any_verbose = True
611+
self.list_checkpoints_includes_time = True
524612
self.any_dry_run = True
525613
self.any_ignore_non_yang_tables = True
526614
self.any_ignore_paths = ["", "/ACL_TABLE"]
@@ -651,7 +739,26 @@ def test_list_checkpoints__creates_rollbacker_and_list_checkpoints(self):
651739
expected = self.any_checkpoints_list
652740

653741
# Act
654-
actual = generic_updater.list_checkpoints(self.any_verbose)
742+
actual = generic_updater.list_checkpoints(False, self.any_verbose)
743+
744+
# Assert
745+
self.assertCountEqual(expected, actual)
746+
747+
def test_list_checkpoints__creates_rollbacker_and_list_checkpoints_with_time(self):
748+
# Arrange
749+
config_rollbacker = Mock()
750+
config_rollbacker.list_checkpoints.return_value = self.any_checkpoints_list_with_time
751+
752+
factory = Mock()
753+
factory.create_config_rollbacker.side_effect = \
754+
create_side_effect_dict({(str(self.any_verbose),): config_rollbacker})
755+
756+
generic_updater = gu.GenericUpdater(factory)
757+
758+
expected = self.any_checkpoints_list_with_time
759+
760+
# Act
761+
actual = generic_updater.list_checkpoints(self.list_checkpoints_includes_time, self.any_verbose)
655762

656763
# Assert
657764
self.assertCountEqual(expected, actual)
@@ -710,7 +817,7 @@ def test_list_checkpoints__calls_decorated_rollbacker(self):
710817
expected = self.any_checkpoints_list
711818

712819
# Act
713-
actual = self.decorator.list_checkpoints()
820+
actual = self.decorator.list_checkpoints(includes_time=False)
714821

715822
# Assert
716823
self.decorated_config_rollbacker.list_checkpoints.assert_called_once()

0 commit comments

Comments
 (0)