Repository: canonical/operator
Pull request #1170 (2024-03-28 05:34:46)

feat: support change-update notice and pebble-change-updated event
This adds support for the new `pebble-change-updated` event (Juju PR https://github.com/juju/juju/pull/17118), adding a new `PebbleChangeUpdatedEvent` type and `{container_name}_pebble_change_updated` event you can observe.

The event has a `get_change()` method to easily fetch the associated `pebble.Change` object.

It also adds `Harness.pebble_notify` support for the `pebble.NoticeType.CHANGE_UPDATE` notice type. To use that, you say:

```python
notice_id = harness.pebble_notify('<container>', '<change-id>', type=pebble.NoticeType.CHANGE_UPDATE)
```


Patch:
diff --git a/ops/__init__.py b/ops/__init__.py
index 60de7ad..77c6b82 100644
--- a/ops/__init__.py
+++ b/ops/__init__.py
@@ -66,6 +66,7 @@ __all__ = [  # noqa: RUF022 `__all__` is not sorted
     'LeaderSettingsChangedEvent',
     'MetadataLinks',
     'PayloadMeta',
+    'PebbleChangeUpdatedEvent',
     'PebbleCustomNoticeEvent',
     'PebbleNoticeEvent',
     'PebbleReadyEvent',
@@ -208,6 +209,7 @@ from .charm import (
     LeaderSettingsChangedEvent,
     MetadataLinks,
     PayloadMeta,
+    PebbleChangeUpdatedEvent,
     PebbleCustomNoticeEvent,
     PebbleNoticeEvent,
     PebbleReadyEvent,
diff --git a/ops/charm.py b/ops/charm.py
index fb687b4..073ccf8 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -34,7 +34,7 @@ from typing import (
     cast,
 )
 
-from ops import model
+from ops import model, pebble
 from ops._private import yaml
 from ops.framework import (
     EventBase,
@@ -808,6 +808,15 @@ class PebbleCustomNoticeEvent(PebbleNoticeEvent):
     """Event triggered when a Pebble notice of type "custom" is created or repeats."""
 
 
+class PebbleChangeUpdatedEvent(PebbleNoticeEvent):
+    """Event triggered when a Pebble notice of type "change-update" is created or repeats."""
+
+    def get_change(self) -> pebble.Change:
+        """Get the Pebble change associated with this event."""
+        change_id = pebble.ChangeID(self.notice.key)
+        return self.workload.pebble.get_change(change_id)
+
+
 class SecretEvent(HookEvent):
     """Base class for all secret events."""
 
@@ -1187,6 +1196,9 @@ class CharmBase(Object):
             container_name = container_name.replace('-', '_')
             self.on.define_event(f"{container_name}_pebble_ready", PebbleReadyEvent)
             self.on.define_event(f"{container_name}_pebble_custom_notice", PebbleCustomNoticeEvent)
+            self.on.define_event(
+                f"{container_name}_pebble_change_updated",
+                PebbleChangeUpdatedEvent)
 
     @property
     def app(self) -> model.Application:
diff --git a/ops/pebble.py b/ops/pebble.py
index 638885f..0053bc2 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -1314,7 +1314,17 @@ class CheckInfo:
 class NoticeType(enum.Enum):
     """Enum of notice types."""
 
+    CHANGE_UPDATE = 'change-update'
+    """Recorded whenever a change is updated, that is, when it is first
+    spawned or its status was updated. The key for change-update notices is
+    the change ID.
+    """
+
     CUSTOM = 'custom'
+    """A custom notice reported via the Pebble client API or ``pebble notify``.
+    The key and data fields are provided by the user. The key must be in
+    the format ``example.com/path`` to ensure well-namespaced notice keys.
+    """
 
 
 class NoticesUsers(enum.Enum):


Test patch:
diff --git a/ops/testing.py b/ops/testing.py
index 0d761c2..d33ecc4 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -1166,8 +1166,13 @@ class Harness(Generic[CharmType]):
 
         id, new_or_repeated = client._notify(type, key, data=data, repeat_after=repeat_after)
 
-        if self._charm is not None and type == pebble.NoticeType.CUSTOM and new_or_repeated:
-            self.charm.on[container_name].pebble_custom_notice.emit(container, id, type.value, key)
+        if self._charm is not None and new_or_repeated:
+            if type == pebble.NoticeType.CUSTOM:
+                self.charm.on[container_name].pebble_custom_notice.emit(
+                    container, id, type.value, key)
+            elif type == pebble.NoticeType.CHANGE_UPDATE:
+                self.charm.on[container_name].pebble_change_updated.emit(
+                    container, id, type.value, key)
 
         return id
 
@@ -3362,10 +3367,6 @@ class _TestingPebbleClient:
 
         Return a tuple of (notice_id, new_or_repeated).
         """
-        if type != pebble.NoticeType.CUSTOM:
-            message = f'invalid type "{type.value}" (can only add "custom" notices)'
-            raise self._api_error(400, message)
-
         # The shape of the code below is taken from State.AddNotice in Pebble.
         now = datetime.datetime.now(tz=datetime.timezone.utc)
 
diff --git a/test/charms/test_main/src/charm.py b/test/charms/test_main/src/charm.py
index ccab5d6..d10eb09 100755
--- a/test/charms/test_main/src/charm.py
+++ b/test/charms/test_main/src/charm.py
@@ -59,6 +59,7 @@ class Charm(ops.CharmBase):
             on_collect_metrics=[],
             on_test_pebble_ready=[],
             on_test_pebble_custom_notice=[],
+            on_test_pebble_change_updated=[],
 
             on_log_critical_action=[],
             on_log_error_action=[],
@@ -92,6 +93,8 @@ class Charm(ops.CharmBase):
         self.framework.observe(self.on.test_pebble_ready, self._on_test_pebble_ready)
         self.framework.observe(self.on.test_pebble_custom_notice,
                                self._on_test_pebble_custom_notice)
+        self.framework.observe(self.on.test_pebble_change_updated,
+                               self._on_test_pebble_change_updated)
 
         self.framework.observe(self.on.secret_remove, self._on_secret_remove)
         self.framework.observe(self.on.secret_rotate, self._on_secret_rotate)
@@ -197,6 +200,13 @@ class Charm(ops.CharmBase):
         self._stored.observed_event_types.append(type(event).__name__)
         self._stored.test_pebble_custom_notice_data = event.snapshot()
 
+    def _on_test_pebble_change_updated(self, event: ops.PebbleChangeUpdatedEvent):
+        assert event.workload is not None
+        assert isinstance(event.notice, ops.LazyNotice)
+        self._stored.on_test_pebble_change_updated.append(type(event).__name__)
+        self._stored.observed_event_types.append(type(event).__name__)
+        self._stored.test_pebble_change_updated_data = event.snapshot()
+
     def _on_start_action(self, event: ops.ActionEvent):
         assert event.handle.kind == 'start_action', (
             'event action name cannot be different from the one being handled')
diff --git a/test/test_charm.py b/test/test_charm.py
index b471b37..a7392ee 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -18,12 +18,14 @@ import shutil
 import tempfile
 import typing
 import unittest
+import unittest.mock
 from pathlib import Path
 
 import yaml
 
 import ops
 import ops.charm
+from ops import pebble
 from ops.model import ModelError, _ModelBackend
 from ops.storage import SQLiteStorage
 
@@ -354,7 +356,19 @@ storage:
             'StorageAttachedEvent',
         ])
 
-    def test_workload_events(self):
+    @unittest.mock.patch('ops.pebble.Client.get_change')
+    def test_workload_events(self, mock_get_change: unittest.mock.MagicMock):
+        def get_change(change_id: pebble.ChangeID) -> pebble.Change:
+            return pebble.Change.from_dict({
+                'id': pebble.ChangeID(change_id),
+                'kind': 'exec',
+                'ready': False,
+                'spawn-time': '2021-01-28T14:37:02.247202105+13:00',
+                'status': 'Doing',
+                'summary': 'Exec command "foo"',
+            })
+
+        mock_get_change.side_effect = get_change
 
         class MyCharm(ops.CharmBase):
             def __init__(self, *args: typing.Any):
@@ -369,6 +383,10 @@ storage:
                         self.on[workload].pebble_custom_notice,
                         self.on_any_pebble_custom_notice,
                     )
+                    self.framework.observe(
+                        self.on[workload].pebble_change_updated,
+                        self.on_any_pebble_change_updated,
+                    )
 
             def on_any_pebble_ready(self, event: ops.PebbleReadyEvent):
                 self.seen.append(type(event).__name__)
@@ -376,6 +394,12 @@ storage:
             def on_any_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent):
                 self.seen.append(type(event).__name__)
 
+            def on_any_pebble_change_updated(self, event: ops.PebbleChangeUpdatedEvent):
+                self.seen.append(type(event).__name__)
+                change = event.get_change()
+                assert change.id == event.notice.key
+                assert change.kind == 'exec'
+
         # language=YAML
         self.meta = ops.CharmMeta.from_yaml(metadata='''
 name: my-charm
@@ -399,11 +423,18 @@ containers:
         charm.on['containerb'].pebble_custom_notice.emit(
             charm.framework.model.unit.get_container('containerb'), '2', 'custom', 'y')
 
+        charm.on['container-a'].pebble_change_updated.emit(
+            charm.framework.model.unit.get_container('container-a'), '1', 'change-update', '42')
+        charm.on['containerb'].pebble_change_updated.emit(
+            charm.framework.model.unit.get_container('containerb'), '2', 'change-update', '42')
+
         self.assertEqual(charm.seen, [
             'PebbleReadyEvent',
             'PebbleReadyEvent',
             'PebbleCustomNoticeEvent',
             'PebbleCustomNoticeEvent',
+            'PebbleChangeUpdatedEvent',
+            'PebbleChangeUpdatedEvent',
         ])
 
     def test_relations_meta(self):
diff --git a/test/test_main.py b/test/test_main.py
index c22d0b5..4747f98 100644
--- a/test/test_main.py
+++ b/test/test_main.py
@@ -608,6 +608,16 @@ class _TestMain(abc.ABC):
              'notice_id': '123',
              'notice_type': 'custom',
              'notice_key': 'example.com/a'},
+        ), (
+            EventSpec(ops.PebbleChangeUpdatedEvent, 'test_pebble_change_updated',
+                      workload_name='test',
+                      notice_id='456',
+                      notice_type='change-update',
+                      notice_key='42'),
+            {'container_name': 'test',
+             'notice_id': '456',
+             'notice_type': 'change-update',
+             'notice_key': '42'},
         ), (
             EventSpec(ops.SecretChangedEvent, 'secret_changed',
                       secret_id='secret:12345',
diff --git a/test/test_testing.py b/test/test_testing.py
index cc0364c..8521d10 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -1107,7 +1107,7 @@ class TestHarness(unittest.TestCase):
         secret_id = harness.add_model_secret('mycharm', {'key': 'value'})
         harness.update_config(key_values={'a': secret_id})
         self.assertEqual(harness.charm.changes,
-            [{'name': 'config-changed', 'data': {'a': secret_id}}])
+                         [{'name': 'config-changed', 'data': {'a': secret_id}}])
 
     def test_no_config_option_type(self):
         with self.assertRaises(RuntimeError):
@@ -3223,6 +3223,8 @@ class ContainerEventCharm(RecordingCharm):
         self.framework.observe(self.on[container_name].pebble_ready, self._on_pebble_ready)
         self.framework.observe(self.on[container_name].pebble_custom_notice,
                                self._on_pebble_custom_notice)
+        self.framework.observe(self.on[container_name].pebble_change_updated,
+                               self._on_pebble_change_updated)
 
     def _on_pebble_ready(self, event: ops.PebbleReadyEvent):
         self.changes.append({
@@ -3241,6 +3243,17 @@ class ContainerEventCharm(RecordingCharm):
             'notice_key': event.notice.key,
         })
 
+    def _on_pebble_change_updated(self, event: ops.PebbleChangeUpdatedEvent):
+        type_str = (event.notice.type.value if isinstance(event.notice.type, pebble.NoticeType)
+                    else event.notice.type)
+        self.changes.append({
+            'name': 'pebble-change-updated',
+            'container': event.workload.name,
+            'notice_id': event.notice.id,
+            'notice_type': type_str,
+            'notice_key': event.notice.key,
+        })
+
 
 def get_public_methods(obj: object):
     """Get the public attributes of obj to compare to another object."""
@@ -5764,6 +5777,36 @@ class TestNotify(unittest.TestCase):
         self.assertNotEqual(id, '')
         self.assertEqual(num_notices, 0)
 
+    def test_notify_change_update(self):
+        harness = ops.testing.Harness(ContainerEventCharm, meta="""
+            name: notifier
+            containers:
+              foo:
+                resource: foo-image
+        """)
+        self.addCleanup(harness.cleanup)
+        harness.begin()
+        harness.charm.observe_container_events('foo')
+
+        id1 = harness.pebble_notify('foo', '42', type=pebble.NoticeType.CHANGE_UPDATE)
+        id2 = harness.pebble_notify('foo', '43', type=pebble.NoticeType.CHANGE_UPDATE)
+        self.assertNotEqual(id1, id2)
+
+        expected_changes = [{
+            'name': 'pebble-change-updated',
+            'container': 'foo',
+            'notice_id': id1,
+            'notice_type': 'change-update',
+            'notice_key': '42',
+        }, {
+            'name': 'pebble-change-updated',
+            'container': 'foo',
+            'notice_id': id2,
+            'notice_type': 'change-update',
+            'notice_key': '43',
+        }]
+        self.assertEqual(harness.charm.changes, expected_changes)
+
 
 class PebbleNoticesMixin:
     client: ops.pebble.Client