diff --git a/host_modules/docker_service.py b/host_modules/docker_service.py new file mode 100644 index 00000000..f1c7fc8c --- /dev/null +++ b/host_modules/docker_service.py @@ -0,0 +1,143 @@ +"""Docker service handler""" + +from host_modules import host_service +import docker +import signal +import errno + +MOD_NAME = "docker_service" + +# The set of allowed containers that can be managed by this service. +# First element is the image name, second element is the container name. +ALLOWED_CONTAINERS = [ + ("docker-syncd-brcm", "syncd"), + ("docker-acms", "acms"), + ("docker-sonic-gnmi", "gnmi"), + ("docker-sonic-telemetry", "telemetry"), + ("docker-snmp", "snmp"), + ("docker-platform-monitor", "pmon"), + ("docker-lldp", "lldp"), + ("docker-dhcp-relay", "dhcp_relay"), + ("docker-router-advertiser", "radv"), + ("docker-teamd", "teamd"), + ("docker-fpm-frr", "bgp"), + ("docker-orchagent", "swss"), + ("docker-sonic-restapi", "restapi"), + ("docker-eventd", "eventd"), + ("docker-database", "database"), +] + + +def is_allowed_container(container): + """ + Check if the container is allowed to be managed by this service. + + Args: + container (str): The container name. + + Returns: + bool: True if the container is allowed, False otherwise. + """ + for _, allowed_container in ALLOWED_CONTAINERS: + if container == allowed_container: + return True + return False + + +class DockerService(host_service.HostModule): + """ + DBus endpoint that executes the docker command + """ + + @host_service.method( + host_service.bus_name(MOD_NAME), in_signature="s", out_signature="is" + ) + def stop(self, container): + """ + Stop a running Docker container. + + Args: + container (str): The name or ID of the Docker container. + + Returns: + tuple: A tuple containing the exit code (int) and a message indicating the result of the operation. + """ + try: + client = docker.from_env() + if not is_allowed_container(container): + return ( + errno.EPERM, + "Container {} is not allowed to be managed by this service.".format( + container + ), + ) + container = client.containers.get(container) + container.stop() + return 0, "Container {} has been stopped.".format(container.name) + except docker.errors.NotFound: + return errno.ENOENT, "Container {} does not exist.".format(container) + except Exception as e: + return 1, "Failed to stop container {}: {}".format(container, str(e)) + + @host_service.method( + host_service.bus_name(MOD_NAME), in_signature="si", out_signature="is" + ) + def kill(self, container, signal=signal.SIGKILL): + """ + Kill or send a signal to a running Docker container. + + Args: + container (str): The name or ID of the Docker container. + signal (int): The signal to send. Defaults to SIGKILL. + + Returns: + tuple: A tuple containing the exit code (int) and a message indicating the result of the operation. + """ + try: + client = docker.from_env() + if not is_allowed_container(container): + return ( + errno.EPERM, + "Container {} is not allowed to be managed by this service.".format( + container + ), + ) + container = client.containers.get(container) + container.kill(signal=signal) + return 0, "Container {} has been killed with signal {}.".format( + container.name, signal + ) + except docker.errors.NotFound: + return errno.ENOENT, "Container {} does not exist.".format(container) + except Exception as e: + return 1, "Failed to kill container {}: {}".format(container, str(e)) + + @host_service.method( + host_service.bus_name(MOD_NAME), in_signature="s", out_signature="is" + ) + def restart(self, container): + """ + Restart a running Docker container. + + Args: + container (str): The name or ID of the Docker container. + + Returns: + tuple: A tuple containing the exit code (int) and a message indicating the result of the operation. + """ + try: + client = docker.from_env() + if not is_allowed_container(container): + return ( + errno.EPERM, + "Container {} is not allowed to be managed by this service.".format( + container + ), + ) + container = client.containers.get(container) + container.restart() + return 0, "Container {} has been restarted.".format(container.name) + except docker.errors.NotFound: + return errno.ENOENT, "Container {} does not exist.".format(container) + except Exception as e: + return 1, "Failed to restart container {}: {}".format(container, str(e)) diff --git a/tests/host_modules/docker_service_test.py b/tests/host_modules/docker_service_test.py new file mode 100644 index 00000000..0836d826 --- /dev/null +++ b/tests/host_modules/docker_service_test.py @@ -0,0 +1,209 @@ +import errno +import docker +from unittest import mock +from host_modules.docker_service import DockerService + +MOD_NAME = "docker_service" + + +class TestDockerService(object): + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_stop_success(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.return_value.stop.return_value = None + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, _ = docker_service.stop("syncd") + + assert rc == 0, "Return code is wrong" + mock_docker_client.containers.get.assert_called_once_with("syncd") + mock_docker_client.containers.get.return_value.stop.assert_called_once() + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_stop_fail_disallowed(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.stop("bad-container") + + assert rc == errno.EPERM, "Return code is wrong" + assert ( + "not" in msg and "allowed" in msg + ), "Message should contain 'not' and 'allowed'" + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_stop_fail_not_exist(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.side_effect = docker.errors.NotFound( + "Container not found" + ) + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.stop("syncd") + + assert rc == errno.ENOENT, "Return code is wrong" + assert ( + "not" in msg and "exist" in msg + ), "Message should contain 'not' and 'exist'" + mock_docker_client.containers.get.assert_called_once_with("syncd") + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_stop_fail_api_error(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.return_value.stop.side_effect = ( + docker.errors.APIError("API error") + ) + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.stop("syncd") + + assert rc != 0, "Return code is wrong" + assert "API error" in msg, "Message should contain 'API error'" + mock_docker_client.containers.get.assert_called_once_with("syncd") + mock_docker_client.containers.get.return_value.stop.assert_called_once() + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_kill_success(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.return_value.kill.return_value = None + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, _ = docker_service.kill("syncd") + + assert rc == 0, "Return code is wrong" + mock_docker_client.containers.get.assert_called_once_with("syncd") + mock_docker_client.containers.get.return_value.kill.assert_called_once() + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_kill_fail_disallowed(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.kill("bad-container") + + assert rc == errno.EPERM, "Return code is wrong" + assert ( + "not" in msg and "allowed" in msg + ), "Message should contain 'not' and 'allowed'" + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_kill_fail_not_found(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.side_effect = docker.errors.NotFound( + "Container not found" + ) + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.kill("syncd") + + assert rc == errno.ENOENT, "Return code is wrong" + assert ( + "not" in msg and "exist" in msg + ), "Message should contain 'not' and 'exist'" + mock_docker_client.containers.get.assert_called_once_with("syncd") + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_kill_fail_api_error(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.return_value.kill.side_effect = ( + docker.errors.APIError("API error") + ) + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.kill("syncd") + + assert rc != 0, "Return code is wrong" + assert "API error" in msg, "Message should contain 'API error'" + mock_docker_client.containers.get.assert_called_once_with("syncd") + mock_docker_client.containers.get.return_value.kill.assert_called_once() + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_restart_success(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.return_value.restart.return_value = None + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, _ = docker_service.restart("syncd") + + assert rc == 0, "Return code is wrong" + mock_docker_client.containers.get.assert_called_once_with("syncd") + mock_docker_client.containers.get.return_value.restart.assert_called_once() + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_restart_fail_disallowed(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.restart("bad-container") + + assert rc == errno.EPERM, "Return code is wrong" + assert ( + "not" in msg and "allowed" in msg + ), "Message should contain 'not' and 'allowed'" + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_restart_fail_not_found(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.side_effect = docker.errors.NotFound( + "Container not found" + ) + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.restart("syncd") + + assert rc == errno.ENOENT, "Return code is wrong" + assert ( + "not" in msg and "exist" in msg + ), "Message should contain 'not' and 'exist'" + mock_docker_client.containers.get.assert_called_once_with("syncd") + + @mock.patch("dbus.SystemBus") + @mock.patch("dbus.service.BusName") + @mock.patch("dbus.service.Object.__init__") + def test_docker_restart_fail_api_error(self, MockInit, MockBusName, MockSystemBus): + mock_docker_client = mock.Mock() + mock_docker_client.containers.get.return_value.restart.side_effect = ( + docker.errors.APIError("API error") + ) + + with mock.patch.object(docker, "from_env", return_value=mock_docker_client): + docker_service = DockerService(MOD_NAME) + rc, msg = docker_service.restart("syncd") + + assert rc != 0, "Return code is wrong" + assert "API error" in msg, "Message should contain 'API error'" + mock_docker_client.containers.get.assert_called_once_with("syncd") + mock_docker_client.containers.get.return_value.restart.assert_called_once()