Skip to content

Commit d88d8d0

Browse files
authored
Implementation for ImageService.List (#206)
This PR contains implementation for List method in ImageService host module. The method will list the current, next and available SONiC image on the SONiC device. It will be used in GNOI library for verifying OS installation.
1 parent d7e4df5 commit d88d8d0

File tree

2 files changed

+261
-0
lines changed

2 files changed

+261
-0
lines changed

host_modules/image_service.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import requests
1313
import stat
1414
import subprocess
15+
import json
1516

1617
from host_modules import host_service
1718
import tempfile
@@ -133,3 +134,65 @@ def checksum(self, file_path, algorithm):
133134
except Exception as e:
134135
logger.error("Failed to calculate checksum: {}".format(e))
135136
return errno.EIO, str(e)
137+
138+
@host_service.method(
139+
host_service.bus_name(MOD_NAME), in_signature="", out_signature="is"
140+
)
141+
def list_images(self):
142+
"""
143+
List the current, next, and available SONiC images.
144+
145+
Returns:
146+
A tuple with an error code and a JSON string with keys "current", "next", and "available" or an error message.
147+
"""
148+
logger.info("Listing SONiC images")
149+
150+
try:
151+
output = subprocess.check_output(
152+
["/usr/local/bin/sonic-installer", "list"],
153+
stderr=subprocess.STDOUT,
154+
).decode().strip()
155+
result = self._parse_sonic_installer_list(output)
156+
logger.info("List result: {}".format(result))
157+
return 0, json.dumps(result)
158+
except subprocess.CalledProcessError as e:
159+
msg = "Failed to list images: command {} failed with return code {} and message {}".format(e.cmd, e.returncode, e.output.decode())
160+
logger.error(msg)
161+
return e.returncode, msg
162+
163+
def _parse_sonic_installer_list(self, output):
164+
"""
165+
Parse the output of the sonic-installer list command.
166+
167+
Args:
168+
output: The output of the sonic-installer list command.
169+
170+
Returns:
171+
A dictionary with keys "current", "next", and "available" containing the respective images.
172+
"""
173+
current_image = ""
174+
next_image = ""
175+
available_images = []
176+
177+
for line in output.split("\n"):
178+
if "current:" in line.lower():
179+
parts = line.split(":")
180+
if len(parts) > 1:
181+
current_image = parts[1].strip()
182+
elif "next:" in line.lower():
183+
parts = line.split(":")
184+
if len(parts) > 1:
185+
next_image = parts[1].strip()
186+
elif "available:" in line.lower():
187+
continue
188+
else:
189+
available_images.append(line.strip())
190+
191+
logger.info("Current image: {}".format(current_image))
192+
logger.info("Next image: {}".format(next_image))
193+
logger.info("Available images: {}".format(available_images))
194+
return {
195+
"current": current_image or "",
196+
"next": next_image or "",
197+
"available": available_images or [],
198+
}

tests/host_modules/image_service_test.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import stat
66
import pytest
7+
import json
78
from unittest import mock
89
from host_modules.image_service import ImageService
910

@@ -370,3 +371,200 @@ def test_checksum_general_exception(
370371
), "message should contain 'general error'"
371372
mock_isfile.assert_called_once_with(file_path)
372373
mock_open.assert_called_once_with(file_path, "rb")
374+
375+
@mock.patch("dbus.SystemBus")
376+
@mock.patch("dbus.service.BusName")
377+
@mock.patch("dbus.service.Object.__init__")
378+
@mock.patch("subprocess.check_output")
379+
def test_list_images_success(self, mock_check_output, MockInit, MockBusName, MockSystemBus):
380+
"""
381+
Test that the `list_images` method successfully lists the current, next, and available SONiC images.
382+
"""
383+
# Arrange
384+
image_service = ImageService(mod_name="image_service")
385+
mock_output = (
386+
"Current: current_image\n"
387+
"Next: next_image\n"
388+
"Available:\n"
389+
"image1\n"
390+
"image2\n"
391+
)
392+
mock_check_output.return_value = mock_output.encode()
393+
394+
# Act
395+
rc, images_json = image_service.list_images()
396+
images = json.loads(images_json)
397+
398+
# Assert
399+
assert rc == 0, "wrong return value"
400+
assert images["current"] == "current_image", "current image does not match"
401+
assert images["next"] == "next_image", "next image does not match"
402+
assert images["available"] == ["image1", "image2"], "available images do not match"
403+
mock_check_output.assert_called_once_with(
404+
["/usr/local/bin/sonic-installer", "list"],
405+
stderr=subprocess.STDOUT,
406+
)
407+
408+
@mock.patch("dbus.SystemBus")
409+
@mock.patch("dbus.service.BusName")
410+
@mock.patch("dbus.service.Object.__init__")
411+
@mock.patch("subprocess.check_output")
412+
def test_list_images_success_lowercase_output(self, mock_check_output, MockInit, MockBusName, MockSystemBus):
413+
"""
414+
Test that the `list_images` method successfully lists the current, next, and available SONiC images
415+
even if the output from sonic-installer is in lowercase.
416+
"""
417+
# Arrange
418+
image_service = ImageService(mod_name="image_service")
419+
mock_output = (
420+
"current: current_image\n"
421+
"next: next_image\n"
422+
"available:\n"
423+
"image1\n"
424+
"image2\n"
425+
)
426+
mock_check_output.return_value = mock_output.encode()
427+
428+
# Act
429+
rc, images_json = image_service.list_images()
430+
images = json.loads(images_json)
431+
432+
# Assert
433+
assert rc == 0, "wrong return value"
434+
assert images["current"] == "current_image", "current image does not match"
435+
assert images["next"] == "next_image", "next image does not match"
436+
assert images["available"] == ["image1", "image2"], "available images do not match"
437+
mock_check_output.assert_called_once_with(
438+
["/usr/local/bin/sonic-installer", "list"],
439+
stderr=subprocess.STDOUT,
440+
)
441+
442+
@pytest.mark.parametrize(
443+
"mock_output, expected_current, expected_next, expected_available",
444+
[
445+
("Current: \nNext: next_image\nAvailable:\nimage1\nimage2\n", "", "next_image", ["image1", "image2"]),
446+
("Current: current_image\nNext: \nAvailable:\nimage1\nimage2\n", "current_image", "", ["image1", "image2"]),
447+
("Current: current_image\nNext: next_image\nAvailable:\n", "current_image", "next_image", []),
448+
],
449+
)
450+
@mock.patch("dbus.SystemBus")
451+
@mock.patch("dbus.service.BusName")
452+
@mock.patch("dbus.service.Object.__init__")
453+
@mock.patch("subprocess.check_output")
454+
def test_list_images_success_empty_image_output(
455+
self, mock_check_output, MockInit, MockBusName, MockSystemBus, mock_output, expected_current, expected_next, expected_available
456+
):
457+
"""
458+
Test that the `list_images` method successfully lists the current, next, and available SONiC images even if
459+
sonic-installer output empty string for the image name.
460+
"""
461+
# Arrange
462+
image_service = ImageService(mod_name="image_service")
463+
mock_check_output.return_value = mock_output.encode()
464+
465+
# Act
466+
rc, images_json = image_service.list_images()
467+
images = json.loads(images_json)
468+
469+
# Assert
470+
assert rc == 0, "wrong return value"
471+
assert images["current"] == expected_current, "current image does not match"
472+
assert images["next"] == expected_next, "next image does not match"
473+
assert images["available"] == expected_available, "available images do not match"
474+
mock_check_output.assert_called_once_with(
475+
["/usr/local/bin/sonic-installer", "list"],
476+
stderr=subprocess.STDOUT,
477+
)
478+
479+
@pytest.mark.parametrize(
480+
"mock_output, expected_current, expected_next, expected_available",
481+
[
482+
("Next: next_image\nAvailable:\nimage1\nimage2\n", "", "next_image", ["image1", "image2"]),
483+
("Current: current_image\nAvailable:\nimage1\nimage2\n", "current_image", "", ["image1", "image2"]),
484+
("Current: current_image\nNext: next_image\n", "current_image", "next_image", []),
485+
("Available:\nimage1\nimage2\n", "", "", ["image1", "image2"]),
486+
("Current: current_image\nNext: next_image\nAvailable:\n", "current_image", "next_image", []),
487+
],
488+
)
489+
@mock.patch("dbus.SystemBus")
490+
@mock.patch("dbus.service.BusName")
491+
@mock.patch("dbus.service.Object.__init__")
492+
@mock.patch("subprocess.check_output")
493+
def test_list_images_various_missing_lines(
494+
self, mock_check_output, MockInit, MockBusName, MockSystemBus, mock_output, expected_current, expected_next, expected_available
495+
):
496+
"""
497+
Test that the `list_images` method handles various scenarios where the sonic-installer output is missing lines for current, next, or available images.
498+
"""
499+
# Arrange
500+
image_service = ImageService(mod_name="image_service")
501+
mock_check_output.return_value = mock_output.encode()
502+
503+
# Act
504+
rc, images_json = image_service.list_images()
505+
images = json.loads(images_json)
506+
507+
# Assert
508+
assert rc == 0, "wrong return value"
509+
assert images["current"] == expected_current, "current image does not match"
510+
assert images["next"] == expected_next, "next image does not match"
511+
assert images["available"] == expected_available, "available images do not match"
512+
mock_check_output.assert_called_once_with(
513+
["/usr/local/bin/sonic-installer", "list"],
514+
stderr=subprocess.STDOUT,
515+
)
516+
517+
@mock.patch("dbus.SystemBus")
518+
@mock.patch("dbus.service.BusName")
519+
@mock.patch("dbus.service.Object.__init__")
520+
@mock.patch("subprocess.check_output")
521+
def test_list_images_success_empty_available_images(self, mock_check_output, MockInit, MockBusName, MockSystemBus):
522+
"""
523+
Test that the `list_images` method successfully lists the current, next, and available SONiC images.
524+
"""
525+
# Arrange
526+
image_service = ImageService(mod_name="image_service")
527+
mock_output = (
528+
"Current: current_image\n"
529+
"Next: next_image\n"
530+
"Available:\n"
531+
)
532+
mock_check_output.return_value = mock_output.encode()
533+
534+
# Act
535+
rc, images_json = image_service.list_images()
536+
images = json.loads(images_json)
537+
538+
# Assert
539+
assert rc == 0, "wrong return value"
540+
assert images["available"] == [], "available images should be empty"
541+
mock_check_output.assert_called_once_with(
542+
["/usr/local/bin/sonic-installer", "list"],
543+
stderr=subprocess.STDOUT,
544+
)
545+
546+
547+
@mock.patch("dbus.SystemBus")
548+
@mock.patch("dbus.service.BusName")
549+
@mock.patch("dbus.service.Object.__init__")
550+
@mock.patch("subprocess.check_output")
551+
def test_list_images_failed(self, mock_check_output, MockInit, MockBusName, MockSystemBus):
552+
"""
553+
Test that the `list_image` method fails when the subprocess command returns a non-zero exit code.
554+
"""
555+
# Arrange
556+
image_service = ImageService(mod_name="image_service")
557+
mock_check_output.side_effect = subprocess.CalledProcessError(
558+
returncode=1, cmd="sonic-installer list", output=b"Error: command failed"
559+
)
560+
561+
# Act
562+
rc, msg = image_service.list_images()
563+
564+
# Assert
565+
assert rc != 0, "wrong return value"
566+
mock_check_output.assert_called_once_with(
567+
["/usr/local/bin/sonic-installer", "list"],
568+
stderr=subprocess.STDOUT,
569+
)
570+

0 commit comments

Comments
 (0)