Repository: canonical/operator
Pull request #485 (2021-03-15 04:00:26)

Get-services API to talk to Pebble /v1/services
This adds a `get_services` API to the Pebble client, and `get_services` to `model.Container` (along with a `get_service` helper to fetch a single service's status). This corresponds to the Pebble change https://github.com/canonical/pebble/pull/14



Patch:
diff --git a/ops/model.py b/ops/model.py
index 9c5e85c..cfd7a05 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -1068,6 +1068,26 @@ class Container:
         """Get the current effective pebble configuration."""
         return self._pebble.get_plan()
 
+    def get_services(self, *service_names: str) -> typing.List['pebble.ServiceInfo']:
+        """Get a list of service status information.
+
+        If no service names are specified, return status information for all
+        services, otherwise return information for only the given services.
+        """
+        return self._pebble.get_services(service_names)
+
+    def get_service(self, service_name: str) -> 'pebble.ServiceInfo':
+        """Get status information for a single named service.
+
+        Raises model error if service_name is not found.
+        """
+        services = self.get_services(service_name)
+        if not services:
+            raise ModelError('service {!r} not found'.format(service_name))
+        if len(services) > 1:
+            raise RuntimeError('expected 1 service, got {}'.format(len(services)))
+        return services[0]
+
 
 class ContainerMapping(Mapping):
     """Map of container names to Container objects.
diff --git a/ops/pebble.py b/ops/pebble.py
index 2f78aa3..5d20949 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -497,6 +497,59 @@ class Service:
         return 'Service({!r})'.format(self.to_dict())
 
 
+class ServiceStartup(enum.Enum):
+    """Enum of service startup options."""
+
+    ENABLED = 'enabled'
+    DISABLED = 'disabled'
+
+
+class ServiceStatus(enum.Enum):
+    """Enum of service statuses."""
+
+    ACTIVE = 'active'
+    INACTIVE = 'inactive'
+    ERROR = 'error'
+
+
+class ServiceInfo:
+    """Service status information."""
+
+    def __init__(
+        self,
+        name: str,
+        startup: Union[ServiceStartup, str],
+        current: Union[ServiceStatus, str],
+    ):
+        self.name = name
+        self.startup = startup
+        self.current = current
+
+    @classmethod
+    def from_dict(cls, d: Dict) -> 'ServiceInfo':
+        """Create new object from dict parsed from JSON."""
+        try:
+            startup = ServiceStartup(d['startup'])
+        except ValueError:
+            startup = d['startup']
+        try:
+            current = ServiceStatus(d['current'])
+        except ValueError:
+            current = d['current']
+        return cls(
+            name=d['name'],
+            startup=startup,
+            current=current,
+        )
+
+    def __repr__(self):
+        return ('ServiceInfo('
+                'name={self.name!r}, '
+                'startup={self.startup}, '
+                'current={self.current})'
+                ).format(self=self)
+
+
 class Client:
     """Pebble API client."""
 
@@ -701,3 +754,15 @@ class Client:
         """Get the Pebble plan (currently contains only combined services)."""
         result = self._request('GET', '/v1/plan', {'format': 'yaml'})
         return Plan(result['result'])
+
+    def get_services(self, names: List[str] = None) -> List[ServiceInfo]:
+        """Get the service status for the configured services.
+
+        If names is specified, only fetch the service status for the services
+        named.
+        """
+        query = None
+        if names is not None:
+            query = {'names': ','.join(names)}
+        result = self._request('GET', '/v1/services', query)
+        return [ServiceInfo.from_dict(info) for info in result['result']]


Test patch:
diff --git a/test/pebble_cli.py b/test/pebble_cli.py
index ae3e198..954d285 100644
--- a/test/pebble_cli.py
+++ b/test/pebble_cli.py
@@ -59,6 +59,9 @@ def main():
 
     p = subparsers.add_parser('plan', help='show configuration plan (combined layers)')
 
+    p = subparsers.add_parser('services', help='show service status')
+    p.add_argument('service', help='name of service (none means all; multiple ok)', nargs='*')
+
     p = subparsers.add_parser('start', help='start service(s)')
     p.add_argument('service', help='name of service to start (can specify multiple)', nargs='+')
 
@@ -110,6 +113,8 @@ def main():
                                         service=args.service)
         elif args.command == 'plan':
             result = client.get_plan().to_yaml()
+        elif args.command == 'services':
+            result = client.get_services(args.service)
         elif args.command == 'start':
             result = client.start_services(args.service)
         elif args.command == 'stop':
diff --git a/test/test_model.py b/test/test_model.py
index 809d679..c84c649 100755
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -868,6 +868,64 @@ containers:
         self.assertIsInstance(plan, ops.pebble.Plan)
         self.assertEqual(plan.to_yaml(), plan_yaml)
 
+    @staticmethod
+    def _make_service(name, startup, current):
+        return ops.pebble.ServiceInfo.from_dict(
+            {'name': name, 'startup': startup, 'current': current})
+
+    def test_get_services(self):
+        two_services = [
+            self._make_service('s1', 'enabled', 'active'),
+            self._make_service('s2', 'disabled', 'inactive'),
+        ]
+        self.pebble.responses.append(two_services)
+        services = self.container.get_services()
+        self.assertEqual(len(services), 2)
+        self.assertEqual(services[0].name, 's1')
+        self.assertEqual(services[0].startup, ops.pebble.ServiceStartup.ENABLED)
+        self.assertEqual(services[0].current, ops.pebble.ServiceStatus.ACTIVE)
+        self.assertEqual(services[1].name, 's2')
+        self.assertEqual(services[1].startup, ops.pebble.ServiceStartup.DISABLED)
+        self.assertEqual(services[1].current, ops.pebble.ServiceStatus.INACTIVE)
+
+        self.pebble.responses.append(two_services)
+        services = self.container.get_services('s1', 's2')
+        self.assertEqual(len(services), 2)
+        self.assertEqual(services[0].name, 's1')
+        self.assertEqual(services[0].startup, ops.pebble.ServiceStartup.ENABLED)
+        self.assertEqual(services[0].current, ops.pebble.ServiceStatus.ACTIVE)
+        self.assertEqual(services[1].name, 's2')
+        self.assertEqual(services[1].startup, ops.pebble.ServiceStartup.DISABLED)
+        self.assertEqual(services[1].current, ops.pebble.ServiceStatus.INACTIVE)
+
+        self.assertEqual(self.pebble.requests, [
+            ('get_services', ()),
+            ('get_services', ('s1', 's2')),
+        ])
+
+    def test_get_service(self):
+        # Single service returned successfully
+        self.pebble.responses.append([self._make_service('s1', 'enabled', 'active')])
+        s = self.container.get_service('s1')
+        self.assertEqual(self.pebble.requests, [('get_services', ('s1', ))])
+        self.assertEqual(s.name, 's1')
+        self.assertEqual(s.startup, ops.pebble.ServiceStartup.ENABLED)
+        self.assertEqual(s.current, ops.pebble.ServiceStatus.ACTIVE)
+
+        # If Pebble returns no services, should be a ModelError
+        self.pebble.responses.append([])
+        with self.assertRaises(ops.model.ModelError) as cm:
+            self.container.get_service('s2')
+        self.assertEqual(str(cm.exception), "service 's2' not found")
+
+        # If Pebble returns more than one service, RuntimeError is raised
+        self.pebble.responses.append([
+            self._make_service('s1', 'enabled', 'active'),
+            self._make_service('s2', 'disabled', 'inactive'),
+        ])
+        with self.assertRaises(RuntimeError):
+            self.container.get_service('s1')
+
 
 class MockPebbleBackend(ops.model._ModelBackend):
     def get_pebble(self, socket_path):
@@ -900,6 +958,10 @@ class MockPebbleClient:
         self.requests.append(('get_plan',))
         return self.responses.pop(0)
 
+    def get_services(self, names=None):
+        self.requests.append(('get_services', names))
+        return self.responses.pop(0)
+
 
 class TestModelBindings(unittest.TestCase):
 
diff --git a/test/test_pebble.py b/test/test_pebble.py
index 7b63bff..1e966db 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -504,6 +504,50 @@ class TestService(unittest.TestCase):
         self.assertEqual(d['environment'], [{'k1': 'v1'}, {'k2': 'v2'}])
 
 
+class TestServiceInfo(unittest.TestCase):
+    def test_service_startup(self):
+        self.assertEqual(list(pebble.ServiceStartup), [
+            pebble.ServiceStartup.ENABLED,
+            pebble.ServiceStartup.DISABLED,
+        ])
+        self.assertEqual(pebble.ServiceStartup.ENABLED.value, 'enabled')
+        self.assertEqual(pebble.ServiceStartup.DISABLED.value, 'disabled')
+
+    def test_service_status(self):
+        self.assertEqual(list(pebble.ServiceStatus), [
+            pebble.ServiceStatus.ACTIVE,
+            pebble.ServiceStatus.INACTIVE,
+            pebble.ServiceStatus.ERROR,
+        ])
+        self.assertEqual(pebble.ServiceStatus.ACTIVE.value, 'active')
+        self.assertEqual(pebble.ServiceStatus.INACTIVE.value, 'inactive')
+        self.assertEqual(pebble.ServiceStatus.ERROR.value, 'error')
+
+    def test_service_info(self):
+        s = pebble.ServiceInfo('svc1', pebble.ServiceStartup.ENABLED, pebble.ServiceStatus.ACTIVE)
+        self.assertEqual(s.name, 'svc1')
+        self.assertEqual(s.startup, pebble.ServiceStartup.ENABLED)
+        self.assertEqual(s.current, pebble.ServiceStatus.ACTIVE)
+
+        s = pebble.ServiceInfo.from_dict({
+            'name': 'svc2',
+            'startup': 'disabled',
+            'current': 'inactive',
+        })
+        self.assertEqual(s.name, 'svc2')
+        self.assertEqual(s.startup, pebble.ServiceStartup.DISABLED)
+        self.assertEqual(s.current, pebble.ServiceStatus.INACTIVE)
+
+        s = pebble.ServiceInfo.from_dict({
+            'name': 'svc2',
+            'startup': 'thingy',
+            'current': 'bob',
+        })
+        self.assertEqual(s.name, 'svc2')
+        self.assertEqual(s.startup, 'thingy')
+        self.assertEqual(s.current, 'bob')
+
+
 class MockClient(pebble.Client):
     """Mock Pebble client that simply records reqeusts and returns stored responses."""
 
@@ -940,6 +984,87 @@ services:
             ('GET', '/v1/plan', {'format': 'yaml'}, None),
         ])
 
+    def test_get_services_all(self):
+        self.client.responses.append({
+            "result": [
+                {
+                    "current": "inactive",
+                    "name": "svc1",
+                    "startup": "disabled"
+                },
+                {
+                    "current": "active",
+                    "name": "svc2",
+                    "startup": "enabled"
+                }
+            ],
+            "status": "OK",
+            "status-code": 200,
+            "type": "sync"
+        })
+        services = self.client.get_services()
+        self.assertEqual(len(services), 2)
+        self.assertEqual(services[0].name, 'svc1')
+        self.assertEqual(services[0].startup, pebble.ServiceStartup.DISABLED)
+        self.assertEqual(services[0].current, pebble.ServiceStatus.INACTIVE)
+        self.assertEqual(services[1].name, 'svc2')
+        self.assertEqual(services[1].startup, pebble.ServiceStartup.ENABLED)
+        self.assertEqual(services[1].current, pebble.ServiceStatus.ACTIVE)
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/services', None, None),
+        ])
+
+    def test_get_services_names(self):
+        self.client.responses.append({
+            "result": [
+                {
+                    "current": "inactive",
+                    "name": "svc1",
+                    "startup": "disabled"
+                },
+                {
+                    "current": "active",
+                    "name": "svc2",
+                    "startup": "enabled"
+                }
+            ],
+            "status": "OK",
+            "status-code": 200,
+            "type": "sync"
+        })
+        services = self.client.get_services(['svc1', 'svc2'])
+        self.assertEqual(len(services), 2)
+        self.assertEqual(services[0].name, 'svc1')
+        self.assertEqual(services[0].startup, pebble.ServiceStartup.DISABLED)
+        self.assertEqual(services[0].current, pebble.ServiceStatus.INACTIVE)
+        self.assertEqual(services[1].name, 'svc2')
+        self.assertEqual(services[1].startup, pebble.ServiceStartup.ENABLED)
+        self.assertEqual(services[1].current, pebble.ServiceStatus.ACTIVE)
+
+        self.client.responses.append({
+            "result": [
+                {
+                    "current": "active",
+                    "name": "svc2",
+                    "startup": "enabled"
+                }
+            ],
+            "status": "OK",
+            "status-code": 200,
+            "type": "sync"
+        })
+        services = self.client.get_services(['svc2'])
+        self.assertEqual(len(services), 1)
+        self.assertEqual(services[0].name, 'svc2')
+        self.assertEqual(services[0].startup, pebble.ServiceStartup.ENABLED)
+        self.assertEqual(services[0].current, pebble.ServiceStatus.ACTIVE)
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/services', {'names': 'svc1,svc2'}, None),
+            ('GET', '/v1/services', {'names': 'svc2'}, None),
+        ])
+
 
 class TestSocketClient(unittest.TestCase):
     @unittest.skipIf(sys.platform == 'win32', "Unix sockets don't work on Windows")