7 Commits 20dafb5c80 ... 59e043a7be

Author SHA1 Message Date
  Fabian Peter Hammerle 59e043a7be release v0.2.0 3 weeks ago
  Fabian Peter Hammerle 7887079ebe make: added docker-push target 3 weeks ago
  Fabian Peter Hammerle 54a3c74eae make: added docker-build target 3 weeks ago
  Fabian Peter Hammerle f348d613ce retain msgs on systemctl/hostname/preparing-for-shutdown 3 weeks ago
  Fabian Peter Hammerle 2511d653f4 added unit tests for _State.publish_preparing_for_shutdown 3 weeks ago
  Fabian Peter Hammerle 47bb97bb07 retain msgs on systemctl/hostname/preparing-for-shutdown 3 weeks ago
  Fabian Peter Hammerle 838bf98f58 apparmor profile: allow access to libs & dbus methods required to receive shutdown notifications & report inhibitor locks 3 weeks ago
6 changed files with 122 additions and 11 deletions
  1. 4 1
      CHANGELOG.md
  2. 15 0
      Makefile
  3. 16 1
      docker-apparmor-profile
  4. 31 3
      systemctl_mqtt/__init__.py
  5. 12 5
      tests/test_mqtt.py
  6. 44 1
      tests/test_state_dbus.py

+ 4 - 1
CHANGELOG.md

@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+
+## [0.2.0] - 2020-06-21
 ### Added
 - forward logind's [PreparingForShutdown](https://www.freedesktop.org/wiki/Software/systemd/inhibit/)
   to `systemctl/hostname/preparing-for-shutdown`
@@ -28,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - MQTT message on topic `systemctl/hostname/poweroff`
   schedules a poweroff via systemd's dbus interface (4 seconds delay)
 
-[Unreleased]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.1.1...HEAD
+[Unreleased]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.2.0...HEAD
+[0.2.0]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.1.1...v0.2.0
 [0.1.1]: https://github.com/fphammerle/systemctl-mqtt/compare/v0.1.0...v0.1.1
 [0.1.0]: https://github.com/fphammerle/systemctl-mqtt/releases/tag/v0.1.0

+ 15 - 0
Makefile

@@ -0,0 +1,15 @@
+DOCKER_IMAGE_NAME := fphammerle/systemctl-mqtt
+DOCKER_TAG_VERSION := $(shell git describe --match=v* --dirty | sed -e 's/^v//')
+ARCH := $(shell arch)
+DOCKER_TAG_ARCH_SUFFIX_armv6l := armv6
+DOCKER_TAG_ARCH_SUFFIX_x86_64 := amd64
+DOCKER_TAG_ARCH_SUFFIX = ${DOCKER_TAG_ARCH_SUFFIX_${ARCH}}
+DOCKER_TAG = ${DOCKER_TAG_VERSION}-${DOCKER_TAG_ARCH_SUFFIX}
+
+.PHONY: docker-build docker-push
+
+docker-build:
+	sudo docker build -t "${DOCKER_IMAGE_NAME}:${DOCKER_TAG}" .
+
+docker-push: docker-build
+	sudo docker push "${DOCKER_IMAGE_NAME}:${DOCKER_TAG}"

+ 16 - 1
docker-apparmor-profile

@@ -26,6 +26,9 @@ profile systemctl-mqtt flags=(attach_disconnected) {
   /systemctl-mqtt/ r,
   /systemctl-mqtt/** r,
   /systemctl-mqtt/.venv/lib/python3.8/site-packages/_dbus_bindings.so m,
+  /systemctl-mqtt/.venv/lib/python3.8/site-packages/_dbus_glib_bindings.so m,
+  /systemctl-mqtt/.venv/lib/python3.8/site-packages/gi/_gi.cpython-38-x86_64-linux-gnu.so m,
+  /systemctl-mqtt/.venv/lib/python3.8/site-packages/gi/_gi_cairo.cpython-38-x86_64-linux-gnu.so m,
   # https://presentations.nordisch.org/apparmor/#/25
   /systemctl-mqtt/.venv/bin/systemctl-mqtt rix,
   /etc/** r,
@@ -47,6 +50,18 @@ profile systemctl-mqtt flags=(attach_disconnected) {
        bus=system
        path=/org/freedesktop/login1
        interface=org.freedesktop.login1.Manager
-       member=ScheduleShutdown
+       member={Inhibit,ListInhibitors,ScheduleShutdown}
+       peer=(label=unconfined),
+  dbus (receive)
+       bus=system
+       path=/org/freedesktop/login1
+       interface=org.freedesktop.login1.Manager
+       member=PrepareForShutdown
+       peer=(label=unconfined),
+  dbus (send)
+       bus=system
+       path=/org/freedesktop/login1
+       interface=org.freedesktop.DBus.Properties
+       member=Get
        peer=(label=unconfined),
 }

+ 31 - 3
systemctl_mqtt/__init__.py

@@ -143,15 +143,17 @@ class _State:
                 self._shutdown_lock = None
 
     def _publish_preparing_for_shutdown(
-        self, mqtt_client: paho.mqtt.client.Client, active: bool
+        self, mqtt_client: paho.mqtt.client.Client, active: bool, block: bool,
     ) -> None:
         # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1199
         topic = self.mqtt_topic_prefix + "/preparing-for-shutdown"
         payload = json.dumps(active)
         _LOGGER.info("publishing %r on %s", payload, topic)
         msg_info = mqtt_client.publish(
-            topic=topic, payload=payload,
+            topic=topic, payload=payload, retain=True,
         )  # type: paho.mqtt.client.MQTTMessageInfo
+        if not block:
+            return
         msg_info.wait_for_publish()
         if msg_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
             _LOGGER.error(
@@ -163,7 +165,9 @@ class _State:
     ) -> None:
         assert isinstance(active, dbus.Boolean)
         active = bool(active)
-        self._publish_preparing_for_shutdown(mqtt_client=mqtt_client, active=active)
+        self._publish_preparing_for_shutdown(
+            mqtt_client=mqtt_client, active=active, block=True,
+        )
         if active:
             self.release_shutdown_lock()
         else:
@@ -179,6 +183,29 @@ class _State:
             ),
         )
 
+    def publish_preparing_for_shutdown(
+        self, mqtt_client: paho.mqtt.client.Client,
+    ) -> None:
+        try:
+            active = self._login_manager.Get(
+                "org.freedesktop.login1.Manager",
+                "PreparingForShutdown",
+                dbus_interface="org.freedesktop.DBus.Properties",
+            )
+        except dbus.DBusException as exc:
+            _LOGGER.error(
+                "failed to read logind's PreparingForShutdown property: %s",
+                exc.get_dbus_message(),
+            )
+            return
+        assert isinstance(active, dbus.Boolean), active
+        self._publish_preparing_for_shutdown(
+            mqtt_client=mqtt_client,
+            active=bool(active),
+            # https://github.com/eclipse/paho.mqtt.python/issues/439#issuecomment-565514393
+            block=False,
+        )
+
 
 class _MQTTAction:
 
@@ -226,6 +253,7 @@ def _mqtt_on_connect(
     _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
     state.acquire_shutdown_lock()
     state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client)
+    state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
     for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
         topic = state.mqtt_topic_prefix + "/" + topic_suffix
         _LOGGER.info("subscribing to %s", topic)

+ 12 - 5
tests/test_mqtt.py

@@ -20,6 +20,7 @@ import threading
 import time
 import unittest.mock
 
+import dbus
 import paho.mqtt.client
 import pytest
 from paho.mqtt.client import MQTTMessage
@@ -44,8 +45,9 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
         "gi.repository.GLib.MainLoop.run"
     ) as glib_loop_mock, unittest.mock.patch(
         "systemctl_mqtt._get_login_manager"
-    ):
+    ) as get_login_manager_mock:
         ssl_wrap_socket_mock.return_value.send = len
+        get_login_manager_mock.return_value.Get.return_value = dbus.Boolean(False)
         systemctl_mqtt._run(
             mqtt_host=mqtt_host,
             mqtt_port=mqtt_port,
@@ -102,11 +104,15 @@ def test__run(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix):
     assert caplog.records[1].levelno == logging.DEBUG
     assert caplog.records[1].message == "acquired shutdown inhibitor lock"
     assert caplog.records[2].levelno == logging.INFO
-    assert caplog.records[2].message == "subscribing to {}".format(
+    assert caplog.records[2].message == "publishing 'false' on {}".format(
+        mqtt_topic_prefix + "/preparing-for-shutdown"
+    )
+    assert caplog.records[3].levelno == logging.INFO
+    assert caplog.records[3].message == "subscribing to {}".format(
         mqtt_topic_prefix + "/poweroff"
     )
-    assert caplog.records[3].levelno == logging.DEBUG
-    assert caplog.records[3].message == "registered MQTT callback for topic {}".format(
+    assert caplog.records[4].levelno == logging.DEBUG
+    assert caplog.records[4].message == "registered MQTT callback for topic {}".format(
         mqtt_topic_prefix + "/poweroff"
     ) + " triggering {}".format(
         systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"].action
@@ -163,8 +169,9 @@ def _initialize_mqtt_client(
         "gi.repository.GLib.MainLoop.run"
     ), unittest.mock.patch(
         "systemctl_mqtt._get_login_manager"
-    ):
+    ) as get_login_manager_mock:
         ssl_wrap_socket_mock.return_value.send = len
+        get_login_manager_mock.return_value.Get.return_value = dbus.Boolean(False)
         systemctl_mqtt._run(
             mqtt_host=mqtt_host,
             mqtt_port=mqtt_port,

+ 44 - 1
tests/test_state_dbus.py

@@ -67,10 +67,53 @@ def test_prepare_for_shutdown_handler(caplog, active):
         acquire_lock_mock.assert_called_once_with()
         release_lock_mock.assert_not_called()
     mqtt_client_mock.publish.assert_called_once_with(
-        topic="any/preparing-for-shutdown", payload="true" if active else "false",
+        topic="any/preparing-for-shutdown",
+        payload="true" if active else "false",
+        retain=True,
     )
     assert len(caplog.records) == 1
     assert caplog.records[0].levelno == logging.ERROR
     assert caplog.records[0].message.startswith(
         "failed to publish on any/preparing-for-shutdown"
     )
+
+
+@pytest.mark.parametrize("active", [True, False])
+def test_publish_preparing_for_shutdown(active):
+    login_manager_mock = unittest.mock.MagicMock()
+    login_manager_mock.Get.return_value = dbus.Boolean(active)
+    with unittest.mock.patch(
+        "systemctl_mqtt._get_login_manager", return_value=login_manager_mock
+    ):
+        state = systemctl_mqtt._State(mqtt_topic_prefix="any")
+    assert state._login_manager == login_manager_mock
+    mqtt_client_mock = unittest.mock.MagicMock()
+    state.publish_preparing_for_shutdown(mqtt_client=mqtt_client_mock)
+    login_manager_mock.Get.assert_called_once_with(
+        "org.freedesktop.login1.Manager",
+        "PreparingForShutdown",
+        dbus_interface="org.freedesktop.DBus.Properties",
+    )
+    mqtt_client_mock.publish.assert_called_once_with(
+        topic="any/preparing-for-shutdown",
+        payload="true" if active else "false",
+        retain=True,
+    )
+
+
+def test_publish_preparing_for_shutdown_get_fail(caplog):
+    login_manager_mock = unittest.mock.MagicMock()
+    login_manager_mock.Get.side_effect = dbus.DBusException("mocked")
+    with unittest.mock.patch(
+        "systemctl_mqtt._get_login_manager", return_value=login_manager_mock
+    ):
+        state = systemctl_mqtt._State(mqtt_topic_prefix="any")
+    mqtt_client_mock = unittest.mock.MagicMock()
+    state.publish_preparing_for_shutdown(mqtt_client=None)
+    mqtt_client_mock.publish.assert_not_called()
+    assert len(caplog.records) == 1
+    assert caplog.records[0].levelno == logging.ERROR
+    assert (
+        caplog.records[0].message
+        == "failed to read logind's PreparingForShutdown property: mocked"
+    )