Repository: canonical/operator
Pull request #669 (2021-12-03 01:56:11)

Add send_signal method to pebble.Client and model.Container
This is the Python Operator Framework client part of the send-signals spec. The new `/v1/signals` Pebble API is being added in https://github.com/canonical/pebble/pull/92


Patch:
diff --git a/ops/model.py b/ops/model.py
index c20d883..06f48f2 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -1329,6 +1329,23 @@ class Container:
             combine_stderr=combine_stderr,
         )
 
+    def send_signal(self, sig: typing.Union[int, str], *service_names: str):
+        """Send the given signal to one or more services.
+
+        Args:
+            sig: Name or number of signal to send, e.g., "SIGHUP", 1, or
+                signal.SIGHUP.
+            service_names: Name(s) of the service(s) to send the signal to.
+
+        Raises:
+            pebble.APIError: If any of the services are not in the plan or are
+                not currently running.
+        """
+        if not service_names:
+            raise TypeError('send_signal expected at least 1 service name, got 0')
+
+        self._pebble.send_signal(sig, service_names)
+
 
 class ContainerMapping(Mapping):
     """Map of container names to Container objects.
diff --git a/ops/pebble.py b/ops/pebble.py
index 8c57d85..c9a71b4 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -1926,3 +1926,30 @@ class Client:
         base_url = self.base_url.replace('http://', 'ws://')
         url = '{}/v1/tasks/{}/websocket/{}'.format(base_url, task_id, websocket_id)
         return url
+
+    def send_signal(self, sig: typing.Union[int, str], services: typing.List[str]):
+        """Send the given signal to the list of services named.
+
+        Args:
+            sig: Name or number of signal to send, e.g., "SIGHUP", 1, or
+                signal.SIGHUP.
+            services: Non-empty list of service names to send the signal to.
+
+        Raises:
+            APIError: If any of the services are not in the plan or are not
+                currently running.
+        """
+        if not isinstance(services, (list, tuple)):
+            raise TypeError('services must be a list of str, not {}'.format(
+                type(services).__name__))
+        for s in services:
+            if not isinstance(s, str):
+                raise TypeError('service names must be str, not {}'.format(type(s).__name__))
+
+        if isinstance(sig, int):
+            sig = signal.Signals(sig).name
+        body = {
+            'signal': sig,
+            'services': services,
+        }
+        self._request('POST', '/v1/signals', body=body)


Test patch:
diff --git a/ops/testing.py b/ops/testing.py
index 540327d..ccbd032 100755
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -1443,6 +1443,9 @@ ChangeError: cannot perform the following tasks:
     def exec(self, command, **kwargs):
         raise NotImplementedError(self.exec)
 
+    def send_signal(self, sig: typing.Union[int, str], services: typing.List[str]):
+        raise NotImplementedError(self.send_signal)
+
 
 class NonAbsolutePathError(Exception):
     """Error raised by _MockFilesystem.
diff --git a/test/test_model.py b/test/test_model.py
index bdb8017..c8e890f 100755
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -1199,6 +1199,22 @@ containers:
         ])
         self.assertEqual(p, 'fake_exec_process')
 
+    def test_send_signal(self):
+        with self.assertRaises(TypeError):
+            self.container.send_signal('SIGHUP')
+
+        self.container.send_signal('SIGHUP', 's1')
+        self.assertEqual(self.pebble.requests, [
+            ('send_signal', 'SIGHUP', ('s1',)),
+        ])
+        self.pebble.requests = []
+
+        self.container.send_signal('SIGHUP', 's1', 's2')
+        self.assertEqual(self.pebble.requests, [
+            ('send_signal', 'SIGHUP', ('s1', 's2')),
+        ])
+        self.pebble.requests = []
+
 
 class MockPebbleBackend(ops.model._ModelBackend):
     def get_pebble(self, socket_path):
@@ -1269,6 +1285,9 @@ class MockPebbleClient:
         self.requests.append(('exec', command, kwargs))
         return self.responses.pop(0)
 
+    def send_signal(self, signal, service_names):
+        self.requests.append(('send_signal', signal, service_names))
+
 
 class TestModelBindings(unittest.TestCase):
 
diff --git a/test/test_pebble.py b/test/test_pebble.py
index 54c8f49..a54fabd 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -2009,6 +2009,42 @@ bad path
         self.assertEqual(cm.exception.kind, 'generic-file-error')
         self.assertEqual(cm.exception.message, 'some other error')
 
+    def test_send_signal_name(self):
+        self.client.responses.append({
+            'result': True,
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+
+        self.client.send_signal('SIGHUP', ['s1', 's2'])
+
+        self.assertEqual(self.client.requests, [
+            ('POST', '/v1/signals', None, {'signal': 'SIGHUP', 'services': ['s1', 's2']}),
+        ])
+
+    @unittest.skipUnless(hasattr(signal, 'SIGHUP'), 'signal constants not present on Windows')
+    def test_send_signal_number(self):
+        self.client.responses.append({
+            'result': True,
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+
+        self.client.send_signal(signal.SIGHUP, ['s1', 's2'])
+
+        self.assertEqual(self.client.requests, [
+            ('POST', '/v1/signals', None, {'signal': 'SIGHUP', 'services': ['s1', 's2']}),
+        ])
+
+    def test_send_signal_type_error(self):
+        with self.assertRaises(TypeError):
+            self.client.send_signal('SIGHUP', 'should-be-a-list')
+
+        with self.assertRaises(TypeError):
+            self.client.send_signal('SIGHUP', [1, 2])
+
 
 @unittest.skipIf(sys.platform == 'win32', "Unix sockets don't work on Windows")
 class TestSocketClient(unittest.TestCase):