Repository: canonical/operator
Pull request #1366 (2024-09-06 08:46:02)

docs: note about repeatedly setting secret value in Juju 3.6
https://warthogs.atlassian.net/browse/CHARMTECH-171

dimaqq: @tonyandrewmeyer we don't have a magical "Changed in Juju X.Y" on par with "Added in Juju / Removed in Juju" do we?
tonyandrewmeyer: > @tonyandrewmeyer we don't have a magical "Changed in Juju X.Y" on par with "Added in Juju / Removed in Juju" do we?

No, but if you look at the end of docs/custom_conf.py you'll see that it's trivial to add.

Patch:
diff --git a/.github/workflows/tiobe.yaml b/.github/workflows/tiobe.yaml
index 6456792..6c79c22 100644
--- a/.github/workflows/tiobe.yaml
+++ b/.github/workflows/tiobe.yaml
@@ -18,8 +18,7 @@ jobs:
         run: pip install tox~=4.2 coverage[toml] flake8 pylint websocket-client==1.* pyyaml==6.* pytest~=7.2 pytest-operator~=0.23
       - name: Generate coverage report
         run: |
-          tox -e unit
-          coverage xml
+          tox -e coverage
           # Annoyingly, the coverage.xml file needs to be in a .coverage folder.
           rm .coverage
           mkdir .coverage
diff --git a/.gitignore b/.gitignore
index 30a07a5..c8b24c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ venv
 /docsenv
 .vscode
 .coverage
+coverage.xml
 /.tox
 .*.swp
 
diff --git a/docs/custom_conf.py b/docs/custom_conf.py
index 29e9643..b1ccbc0 100644
--- a/docs/custom_conf.py
+++ b/docs/custom_conf.py
@@ -379,7 +379,7 @@ class JujuVersion(SphinxDirective):
             messages = []
         if self.content:
             node += self.parse_content_to_nodes()
-        classes = ['versionmodified', self.change, 'jujuversion']
+        classes = ['versionmodified', self.change]
         if len(node) > 0 and isinstance(node[0], nodes.paragraph):
             # The contents start with a paragraph.
             if node[0].rawsource:
@@ -418,11 +418,17 @@ class JujuAdded(JujuVersion):
     text = 'Added'
 
 
+class JujuChanged(JujuVersion):
+    change = 'changed'
+    text = 'Changed'
+
+
 class JujuRemoved(JujuVersion):
     change = 'removed'
     text = 'Scheduled for removal'
 
 
 def setup(app):
-    app.add_directive('jujuversion', JujuAdded)
+    app.add_directive('jujuadded', JujuAdded)
+    app.add_directive('jujuchanged', JujuChanged)
     app.add_directive('jujuremoved', JujuRemoved)
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 0df7bf3..340a580 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -117,7 +117,7 @@ sphinx==8.0.2
     #   sphinx-tabs
     #   sphinxcontrib-jquery
     #   sphinxext-opengraph
-sphinx-autobuild==2024.4.16
+sphinx-autobuild==2024.9.3
     # via ops (pyproject.toml)
 sphinx-basic-ng==1.0.0b2
     # via furo
diff --git a/ops/charm.py b/ops/charm.py
index dcd13d2..c626d11 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -837,7 +837,7 @@ class PebbleNoticeEvent(WorkloadEvent):
 class PebbleCustomNoticeEvent(PebbleNoticeEvent):
     """Event triggered when a Pebble notice of type "custom" is created or repeats.
 
-    .. jujuversion:: 3.4
+    .. jujuadded:: 3.4
     """
 
 
@@ -884,7 +884,7 @@ class PebbleCheckFailedEvent(PebbleCheckEvent):
     if the check is currently failing, check the current status with
     ``event.info.status == ops.pebble.CheckStatus.DOWN``.
 
-    .. jujuversion:: 3.6
+    .. jujuadded:: 3.6
     """
 
 
@@ -895,7 +895,7 @@ class PebbleCheckRecoveredEvent(PebbleCheckEvent):
     state (not simply failed, but failed at least as many times as the
     configured threshold).
 
-    .. jujuversion:: 3.6
+    .. jujuadded:: 3.6
     """
 
 
@@ -944,7 +944,7 @@ class SecretChangedEvent(SecretEvent):
     :meth:`event.secret.get_content() <ops.Secret.get_content>` with ``refresh=True``
     to tell Juju to start tracking the new revision.
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
         Charm secrets added in Juju 3.0, user secrets added in Juju 3.3
     """
 
@@ -956,7 +956,7 @@ class SecretRotateEvent(SecretEvent):
     be rotated. The event will keep firing until the owner creates a new
     revision by calling :meth:`event.secret.set_content() <ops.Secret.set_content>`.
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
     """
 
     def defer(self) -> NoReturn:
@@ -983,7 +983,7 @@ class SecretRemoveEvent(SecretEvent):
     remove the now-unused revision. If the charm does not, then the event will
     be emitted again, when further revisions are ready for removal.
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
     """
 
     def __init__(self, handle: 'Handle', id: str, label: Optional[str], revision: int):
@@ -1020,7 +1020,7 @@ class SecretExpiredEvent(SecretEvent):
     must be removed. The event will keep firing until the owner removes the
     revision by calling :meth:`event.secret.remove_revision() <ops.Secret.remove_revision>`.
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
     """
 
     def __init__(self, handle: 'Handle', id: str, label: Optional[str], revision: int):
@@ -1213,7 +1213,7 @@ class CharmEvents(ObjectEvents):
     """Triggered by Juju on the observer when the secret owner changes its contents (see
     :class:`SecretChangedEvent`).
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
         Charm secrets added in Juju 3.0, user secrets added in Juju 3.3
     """
 
@@ -1221,21 +1221,21 @@ class CharmEvents(ObjectEvents):
     """Triggered by Juju on the owner when a secret's expiration time elapses (see
     :class:`SecretExpiredEvent`).
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
     """
 
     secret_rotate = EventSource(SecretRotateEvent)
     """Triggered by Juju on the owner when the secret's rotation policy elapses (see
     :class:`SecretRotateEvent`).
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
     """
 
     secret_remove = EventSource(SecretRemoveEvent)
     """Triggered by Juju on the owner when a secret revision can be removed (see
     :class:`SecretRemoveEvent`).
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
     """
 
     collect_app_status = EventSource(CollectStatusEvent)
diff --git a/ops/model.py b/ops/model.py
index 9102f30..7571bd2 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -30,6 +30,7 @@ import sys
 import tempfile
 import time
 import typing
+import warnings
 import weakref
 from abc import ABC, abstractmethod
 from pathlib import Path, PurePath
@@ -279,7 +280,7 @@ class Model:
         again, unless ``refresh=True`` is used, or :meth:`Secret.set_content`
         has been called.
 
-        .. jujuversion:: 3.0
+        .. jujuadded:: 3.0
             Charm secrets added in Juju 3.0, user secrets added in Juju 3.3
 
         Args:
@@ -295,7 +296,7 @@ class Model:
             raise TypeError('Must provide an id or label, or both')
         if id is not None:
             # Canonicalize to "secret:<id>" form for consistency in backend calls.
-            id = Secret._canonicalize_id(id)
+            id = Secret._canonicalize_id(id, self.uuid)
         content = self._backend.secret_get(id=id, label=label)
         return Secret(self._backend, id=id, label=label, content=content)
 
@@ -464,7 +465,7 @@ class Application:
     ) -> 'Secret':
         """Create a :class:`Secret` owned by this application.
 
-        .. jujuversion:: 3.0
+        .. jujuadded:: 3.0
 
         Args:
             content: A key-value mapping containing the payload of the secret,
@@ -1182,8 +1183,17 @@ class SecretInfo:
         rotation: Optional[SecretRotate],
         rotates: Optional[datetime.datetime],
         description: Optional[str] = None,
+        *,
+        model_uuid: Optional[str] = None,
     ):
-        self.id = Secret._canonicalize_id(id)
+        if model_uuid is None:
+            warnings.warn(
+                '`model_uuid` should always be provided when creating a '
+                'SecretInfo instance, and will be required in the future.',
+                DeprecationWarning,
+                stacklevel=2,
+            )
+        self.id = Secret._canonicalize_id(id, model_uuid)
         self.label = label
         self.revision = revision
         self.expires = expires
@@ -1192,7 +1202,9 @@ class SecretInfo:
         self.description = description
 
     @classmethod
-    def from_dict(cls, id: str, d: Dict[str, Any]) -> 'SecretInfo':
+    def from_dict(
+        cls, id: str, d: Dict[str, Any], model_uuid: Optional[str] = None
+    ) -> 'SecretInfo':
         """Create new SecretInfo object from ID and dict parsed from JSON."""
         expires = typing.cast(Optional[str], d.get('expiry'))
         try:
@@ -1208,6 +1220,7 @@ class SecretInfo:
             rotation=rotation,
             rotates=timeconv.parse_rfc3339(rotates) if rotates is not None else None,
             description=typing.cast(Optional[str], d.get('description')),
+            model_uuid=model_uuid,
         )
 
     def __repr__(self):
@@ -1233,7 +1246,7 @@ class Secret:
     All secret events have a :code:`.secret` attribute which provides the
     :class:`Secret` associated with that event.
 
-    .. jujuversion:: 3.0
+    .. jujuadded:: 3.0
         Charm secrets added in Juju 3.0, user secrets added in Juju 3.3
     """
 
@@ -1249,7 +1262,7 @@ class Secret:
         if not (id or label):
             raise TypeError('Must provide an id or label, or both')
         if id is not None:
-            id = self._canonicalize_id(id)
+            id = self._canonicalize_id(id, backend.model_uuid)
         self._backend = backend
         self._id = id
         self._label = label
@@ -1264,11 +1277,13 @@ class Secret:
         return f"<Secret {' '.join(fields)}>"
 
     @staticmethod
-    def _canonicalize_id(id: str) -> str:
+    def _canonicalize_id(id: str, model_uuid: Optional[str]) -> str:
         """Return the canonical form of the given secret ID, with the 'secret:' prefix."""
         id = id.strip()
         if not id.startswith('secret:'):
-            id = f'secret:{id}'  # add the prefix if not there already
+            # Add the prefix and, if provided, model UUID.
+            id = f'secret:{id}' if model_uuid is None else f'secret://{model_uuid}/{id}'
+
         return id
 
     @classmethod
@@ -1308,8 +1323,8 @@ class Secret:
         """Locator ID (URI) for this secret.
 
         This has an unfortunate name for historical reasons, as it's not
-        really a unique identifier, but the secret's locator URI, which may or
-        may not include the model UUID (for cross-model secrets).
+        really a unique identifier, but the secret's locator URI, which will
+        include the model UUID (for cross-model secrets).
 
         Charms should treat this as an opaque string for looking up secrets
         and sharing them via relation data. If a charm-local "name" is needed
@@ -1445,6 +1460,10 @@ class Secret:
         secret no longer exists, this method will succeed, but the unit will go
         into error state on completion of the current Juju hook.
 
+        .. jujuchanged:: 3.6
+            A new secret revision will *not* be created if the content being set
+            is identical to the latest revision.
+
         Args:
             content: A key-value mapping containing the payload of the secret,
                 for example :code:`{"password": "foo123"}`.
@@ -2924,7 +2943,7 @@ class Container:
     def get_notice(self, id: str) -> pebble.Notice:
         """Get details about a single notice by ID.
 
-        .. jujuversion:: 3.4
+        .. jujuadded:: 3.4
 
         Raises:
             ModelError: if a notice with the given ID is not found
@@ -2949,7 +2968,7 @@ class Container:
         See :meth:`ops.pebble.Client.get_notices` for documentation of the
         parameters.
 
-        .. jujuversion:: 3.4
+        .. jujuadded:: 3.4
         """
         return self._pebble.get_notices(
             users=users,
@@ -3604,7 +3623,9 @@ class _ModelBackend:
         result = self._run_for_secret('secret-info-get', *args, return_output=True, use_json=True)
         info_dicts = typing.cast(Dict[str, Any], result)
         id = list(info_dicts)[0]  # Juju returns dict of {secret_id: {info}}
-        return SecretInfo.from_dict(id, typing.cast(Dict[str, Any], info_dicts[id]))
+        return SecretInfo.from_dict(
+            id, typing.cast(Dict[str, Any], info_dicts[id]), model_uuid=self.model_uuid
+        )
 
     def secret_set(
         self,
diff --git a/ops/pebble.py b/ops/pebble.py
index 701b60d..7859415 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -797,7 +797,8 @@ class Plan:
     """Represents the effective Pebble configuration.
 
     A plan is the combined layer configuration. The layer configuration is
-    documented at https://github.com/canonical/pebble/#layer-specification.
+    documented at
+    https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layer-specification/
     """
 
     def __init__(self, raw: Optional[Union[str, PlanDict]] = None):
@@ -873,7 +874,7 @@ class Layer:
     """Represents a Pebble configuration layer.
 
     The format of this is documented at
-    https://github.com/canonical/pebble/#layer-specification.
+    https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layer-specification/
     """
 
     #: Summary of the purpose of this layer.
@@ -1567,7 +1568,7 @@ class Notice:
     last_repeated: datetime.datetime
     """The time this notice was last repeated.
 
-    See Pebble's `Notices documentation <https://github.com/canonical/pebble/#notices>`_
+    See Pebble's `Notices documentation <https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/notices/>`_
     for an explanation of what "repeated" means.
     """
 
diff --git a/tox.ini b/tox.ini
index 72a4c62..cf097a1 100644
--- a/tox.ini
+++ b/tox.ini
@@ -54,10 +54,8 @@ description = Live development: build the Sphinx docs with autoreloading enabled
 deps = {[testenv:docs]deps}
 commands_pre =
     {[testenv:docs]commands_pre}
-# https://github.com/sphinx-doc/sphinx-autobuild/issues/170
-change_dir = {tox_root}/docs
 commands =
-    sphinx-autobuild ./ ./_build/html --watch . --port 8000
+    sphinx-autobuild docs/ docs/_build/html --watch ops/ --port 8000 {posargs}
 
 [testenv:fmt]
 description = Apply coding style standards to code
@@ -113,6 +111,7 @@ deps =
 commands =
     coverage run --source={[vars]src_path} \
              -m pytest --ignore={[vars]tst_path}smoke -v --tb native {posargs}
+    coverage xml
     coverage report
 
 [testenv:pebble]


Test patch:
diff --git a/ops/testing.py b/ops/testing.py
index 9c4d7c9..dcabdf3 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -24,6 +24,7 @@ import ipaddress
 import os
 import pathlib
 import random
+import re
 import shutil
 import signal
 import tempfile
@@ -2702,7 +2703,7 @@ class _TestingModelBackend:
         return len(units) + 1  # Account for this unit.
 
     def _get_secret(self, id: str) -> Optional[_Secret]:
-        return next((s for s in self._secrets if s.id == id), None)
+        return next((s for s in self._secrets if self._secret_ids_are_equal(s.id, id)), None)
 
     def _ensure_secret(self, id: str) -> _Secret:
         secret = self._get_secret(id)
@@ -2724,6 +2725,22 @@ class _TestingModelBackend:
             )
         return secret
 
+    def _secret_ids_are_equal(self, id1: str, id2: str) -> bool:
+        secret_re = re.compile(
+            r'^(?:secret:)?(?://)?(?:(?P<uuid>[a-z0-9-]+)/)?(?P<id>[a-z0-9-]+)$', re.IGNORECASE
+        )
+        mo = secret_re.match(id1)
+        if not mo:
+            return False
+        model_uuid1 = mo.group('uuid') or self.model_uuid
+        id1 = mo.group('id')
+        mo = secret_re.match(id2)
+        if not mo:
+            return False
+        model_uuid2 = mo.group('uuid') or self.model_uuid
+        id2 = mo.group('id')
+        return model_uuid1 == model_uuid2 and id1 == id2
+
     def secret_get(
         self,
         *,
@@ -2816,6 +2833,7 @@ class _TestingModelBackend:
             rotation=rotation,
             rotates=rotates,
             description=secret.description,
+            model_uuid=self.model_uuid,
         )
 
     def secret_set(
@@ -2899,7 +2917,7 @@ class _TestingModelBackend:
             description=description,
         )
         self._secrets.append(secret)
-        return id
+        return id  # Note that this is the 'short' ID, not the canonicalised one.
 
     def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None) -> None:
         secret = self._ensure_secret(id)
@@ -2936,9 +2954,11 @@ class _TestingModelBackend:
                 secret.revisions = revisions
             else:
                 # Last revision removed, remove entire secret
-                self._secrets = [s for s in self._secrets if s.id != id]
+                self._secrets = [
+                    s for s in self._secrets if not self._secret_ids_are_equal(s.id, id)
+                ]
         else:
-            self._secrets = [s for s in self._secrets if s.id != id]
+            self._secrets = [s for s in self._secrets if not self._secret_ids_are_equal(s.id, id)]
 
     def open_port(self, protocol: str, port: Optional[int] = None):
         self._check_protocol_and_port(protocol, port)
diff --git a/test/test_model.py b/test/test_model.py
index c8266dc..f8af184 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -3482,11 +3482,13 @@ class TestSecrets:
         fake_script.write('secret-get', """echo '{"foo": "g"}'""")
 
         secret = model.get_secret(id='123')
-        assert secret.id == 'secret:123'
+        assert secret.id == f'secret://{model._backend.model_uuid}/123'
         assert secret.label is None
         assert secret.get_content() == {'foo': 'g'}
 
-        assert fake_script.calls(clear=True) == [['secret-get', 'secret:123', '--format=json']]
+        assert fake_script.calls(clear=True) == [
+            ['secret-get', f'secret://{model._backend.model_uuid}/123', '--format=json']
+        ]
 
     def test_get_secret_label(self, fake_script: FakeScript, model: ops.Model):
         fake_script.write('secret-get', """echo '{"foo": "g"}'""")
@@ -3502,12 +3504,18 @@ class TestSecrets:
         fake_script.write('secret-get', """echo '{"foo": "h"}'""")
 
         secret = model.get_secret(id='123', label='l')
-        assert secret.id == 'secret:123'
+        assert secret.id == f'secret://{model._backend.model_uuid}/123'
         assert secret.label == 'l'
         assert secret.get_content() == {'foo': 'h'}
 
         assert fake_script.calls(clear=True) == [
-            ['secret-get', 'secret:123', '--label', 'l', '--format=json']
+            [
+                'secret-get',
+                f'secret://{model._backend.model_uuid}/123',
+                '--label',
+                'l',
+                '--format=json',
+            ]
         ]
 
     def test_get_secret_no_args(self, model: ops.Model):
@@ -3537,7 +3545,7 @@ class TestSecrets:
         assert secret.unique_identifier is None
 
         secret = model.get_secret(id='123')
-        assert secret.id == 'secret:123'
+        assert secret.id == f'secret://{model._backend.model_uuid}/123'
         assert secret.unique_identifier == '123'
 
         secret = model.get_secret(id='secret:124')
@@ -3550,7 +3558,7 @@ class TestSecrets:
 
         assert fake_script.calls(clear=True) == [
             ['secret-get', '--label', 'lbl', '--format=json'],
-            ['secret-get', 'secret:123', '--format=json'],
+            ['secret-get', f'secret://{model._backend.model_uuid}/123', '--format=json'],
             ['secret-get', 'secret:124', '--format=json'],
             ['secret-get', 'secret://modeluuid/125', '--format=json'],
         ]
@@ -3566,8 +3574,9 @@ class TestSecretInfo:
             rotation=ops.SecretRotate.MONTHLY,
             rotates=datetime.datetime(2023, 1, 9, 14, 10, 0),
             description='desc',
+            model_uuid='abcd',
         )
-        assert info.id == 'secret:3'
+        assert info.id == 'secret://abcd/3'
         assert info.label == 'lbl'
         assert info.revision == 7
         assert info.expires == datetime.datetime(2022, 12, 9, 14, 10, 0)
@@ -3590,6 +3599,7 @@ class TestSecretInfo:
                 'rotates': '2023-01-09T14:10:00Z',
                 'description': 'desc',
             },
+            model_uuid='abcd',
         )
         assert info.id == 'secret:4'
         assert info.label == 'fromdict'
@@ -3600,14 +3610,15 @@ class TestSecretInfo:
         assert info.description == 'desc'
 
         info = ops.SecretInfo.from_dict(
-            'secret:4',
+            '4',
             {
                 'label': 'fromdict',
                 'revision': 8,
                 'rotation': 'badvalue',
             },
+            model_uuid='abcd',
         )
-        assert info.id == 'secret:4'
+        assert info.id == 'secret://abcd/4'
         assert info.label == 'fromdict'
         assert info.revision == 8
         assert info.expires is None
@@ -3619,11 +3630,15 @@ class TestSecretInfo:
         assert info.id == 'secret:5'
         assert info.revision == 9
 
+        info = ops.SecretInfo.from_dict('secret://abcd/6', {'revision': 9})
+        assert info.id == 'secret://abcd/6'
+        assert info.revision == 9
+
 
 class TestSecretClass:
     @pytest.fixture
     def model(self):
-        return ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0'))
+        return ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0', model_uuid='abcd'))
 
     def make_secret(
         self,
@@ -3636,11 +3651,11 @@ class TestSecretClass:
 
     def test_id_and_label(self, model: ops.Model):
         secret = self.make_secret(model, id=' abc ', label='lbl')
-        assert secret.id == 'secret:abc'
+        assert secret.id == f'secret://{model._backend.model_uuid}/abc'
         assert secret.label == 'lbl'
 
         secret = self.make_secret(model, id='x')
-        assert secret.id == 'secret:x'
+        assert secret.id == f'secret://{model._backend.model_uuid}/x'
         assert secret.label is None
 
         secret = self.make_secret(model, label='y')
@@ -3664,7 +3679,7 @@ class TestSecretClass:
         assert content == {'foo': 'refreshed'}
 
         assert fake_script.calls(clear=True) == [
-            ['secret-get', 'secret:y', '--refresh', '--format=json']
+            ['secret-get', f'secret://{model._backend.model_uuid}/y', '--refresh', '--format=json']
         ]
 
     def test_get_content_uncached(self, model: ops.Model, fake_script: FakeScript):
@@ -3674,7 +3689,9 @@ class TestSecretClass:
         content = secret.get_content()
         assert content == {'foo': 'notcached'}
 
-        assert fake_script.calls(clear=True) == [['secret-get', 'secret:z', '--format=json']]
+        assert fake_script.calls(clear=True) == [
+            ['secret-get', f'secret://{model._backend.model_uuid}/z', '--format=json']
+        ]
 
     def test_get_content_copies_dict(self, model: ops.Model, fake_script: FakeScript):
         fake_script.write('secret-get', """echo '{"foo": "bar"}'""")
@@ -3685,7 +3702,9 @@ class TestSecretClass:
         content['new'] = 'value'
         assert secret.get_content() == {'foo': 'bar'}
 
-        assert fake_script.calls(clear=True) == [['secret-get', 'secret:z', '--format=json']]
+        assert fake_script.calls(clear=True) == [
+            ['secret-get', f'secret://{model._backend.model_uuid}/z', '--format=json']
+        ]
 
     def test_peek_content(self, model: ops.Model, fake_script: FakeScript):
         fake_script.write('secret-get', """echo '{"foo": "peeked"}'""")
@@ -3695,7 +3714,14 @@ class TestSecretClass:
         assert content == {'foo': 'peeked'}
 
         assert fake_script.calls(clear=True) == [
-            ['secret-get', 'secret:a', '--label', 'b', '--peek', '--format=json']
+            [
+                'secret-get',
+                f'secret://{model._backend.model_uuid}/a',
+                '--label',
+                'b',
+                '--peek',
+                '--format=json',
+            ]
         ]
 
     def test_get_info(self, model: ops.Model, fake_script: FakeScript):
@@ -3704,28 +3730,28 @@ class TestSecretClass:
         # Secret with ID only
         secret = self.make_secret(model, id='x')
         info = secret.get_info()
-        assert info.id == 'secret:x'
+        assert info.id == f'secret://{model._backend.model_uuid}/x'
         assert info.label == 'y'
         assert info.revision == 7
 
         # Secret with label only
         secret = self.make_secret(model, label='y')
         info = secret.get_info()
-        assert info.id == 'secret:x'
+        assert info.id == f'secret://{model._backend.model_uuid}/x'
         assert info.label == 'y'
         assert info.revision == 7
 
         # Secret with ID and label
         secret = self.make_secret(model, id='x', label='y')
         info = secret.get_info()
-        assert info.id == 'secret:x'
+        assert info.id == f'secret://{model._backend.model_uuid}/x'
         assert info.label == 'y'
         assert info.revision == 7
 
         assert fake_script.calls(clear=True) == [
-            ['secret-info-get', 'secret:x', '--format=json'],
+            ['secret-info-get', f'secret://{model._backend.model_uuid}/x', '--format=json'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-info-get', 'secret:x', '--format=json'],
+            ['secret-info-get', f'secret://{model._backend.model_uuid}/x', '--format=json'],
         ]
 
     def test_set_content(self, model: ops.Model, fake_script: FakeScript):
@@ -3739,15 +3765,15 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.set_content({'bar': 'foo'})
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         with pytest.raises(ValueError):
             secret.set_content({'s': 't'})  # ensure it validates content (key too short)
 
         assert fake_script.calls(clear=True) == [
-            ['secret-set', 'secret:x', ANY],
+            ['secret-set', f'secret://{model._backend.model_uuid}/x', ANY],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-set', 'secret:z', ANY],
+            ['secret-set', f'secret://{model._backend.model_uuid}/z', ANY],
         ]
         assert fake_script.secrets() == {'foo': 'bar', 'bar': 'foo'}
 
@@ -3768,12 +3794,12 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.set_info(label='lbl')
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
             [
                 'secret-set',
-                'secret:x',
+                f'secret://{model._backend.model_uuid}/x',
                 '--label',
                 'lab',
                 '--description',
@@ -3784,7 +3810,7 @@ class TestSecretClass:
                 'monthly',
             ],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-set', 'secret:z', '--label', 'lbl'],
+            ['secret-set', f'secret://{model._backend.model_uuid}/z', '--label', 'lbl'],
         ]
 
         with pytest.raises(TypeError):
@@ -3811,16 +3837,23 @@ class TestSecretClass:
         assert secret.id is None
         rel345 = ops.Relation('test', 345, True, unit, backend, cache)
         secret.grant(rel345)
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
             ['relation-list', '-r', '123', '--format=json'],
             ['relation-list', '-r', '234', '--format=json'],
-            ['secret-grant', 'secret:x', '--relation', '123'],
-            ['secret-grant', 'secret:x', '--relation', '234', '--unit', 'app/0'],
+            ['secret-grant', f'secret://{model._backend.model_uuid}/x', '--relation', '123'],
+            [
+                'secret-grant',
+                f'secret://{model._backend.model_uuid}/x',
+                '--relation',
+                '234',
+                '--unit',
+                'app/0',
+            ],
             ['relation-list', '-r', '345', '--format=json'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-grant', 'secret:z', '--relation', '345'],
+            ['secret-grant', f'secret://{model._backend.model_uuid}/z', '--relation', '345'],
         ]
 
     def test_revoke(self, model: ops.Model, fake_script: FakeScript):
@@ -3841,16 +3874,23 @@ class TestSecretClass:
         assert secret.id is None
         rel345 = ops.Relation('test', 345, True, unit, model._backend, model._cache)
         secret.revoke(rel345)
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
             ['relation-list', '-r', '123', '--format=json'],
             ['relation-list', '-r', '234', '--format=json'],
-            ['secret-revoke', 'secret:x', '--relation', '123'],
-            ['secret-revoke', 'secret:x', '--relation', '234', '--unit', 'app/0'],
+            ['secret-revoke', f'secret://{model._backend.model_uuid}/x', '--relation', '123'],
+            [
+                'secret-revoke',
+                f'secret://{model._backend.model_uuid}/x',
+                '--relation',
+                '234',
+                '--unit',
+                'app/0',
+            ],
             ['relation-list', '-r', '345', '--format=json'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-revoke', 'secret:z', '--relation', '345'],
+            ['secret-revoke', f'secret://{model._backend.model_uuid}/z', '--relation', '345'],
         ]
 
     def test_remove_revision(self, model: ops.Model, fake_script: FakeScript):
@@ -3864,12 +3904,12 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.remove_revision(234)
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
-            ['secret-remove', 'secret:x', '--revision', '123'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/x', '--revision', '123'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-remove', 'secret:z', '--revision', '234'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/z', '--revision', '234'],
         ]
 
     def test_remove_all_revisions(self, model: ops.Model, fake_script: FakeScript):
@@ -3883,12 +3923,12 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.remove_all_revisions()
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
-            ['secret-remove', 'secret:x'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/x'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-remove', 'secret:z'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/z'],
         ]
 
 
diff --git a/test/test_real_pebble.py b/test/test_real_pebble.py
index b3abac3..a788f72 100644
--- a/test/test_real_pebble.py
+++ b/test/test_real_pebble.py
@@ -322,12 +322,6 @@ class TestPebbleStorageAPIsUsingRealPebble(PebbleStorageAPIsTestMixin):
         yield pebble_dir
         shutil.rmtree(pebble_dir)
 
-    # Remove this entirely once the associated bug is fixed; it overrides the original test in the
-    # test mixin class.
-    @pytest.mark.skip(reason='pending resolution of https://github.com/canonical/pebble/issues/80')
-    def test_make_dir_with_permission_mask(self):
-        pass
-
 
 @pytest.mark.skipif(
     os.getenv('RUN_REAL_PEBBLE_TESTS') != '1',
diff --git a/test/test_testing.py b/test/test_testing.py
index d49a64b..e9e34f3 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -6161,6 +6161,34 @@ class TestSecrets:
         with pytest.raises(RuntimeError):
             secret.remove_all_revisions()
 
+    def test_secret_id_variants(self, request: pytest.FixtureRequest):
+        harness = ops.testing.Harness(
+            ops.CharmBase, meta='name: webapp\nrequires:\n db:\n  interface: database'
+        )
+        request.addfinalizer(harness.cleanup)
+        harness.add_relation('db', 'database')
+        harness.begin()
+
+        # Local (app and unit) secrets, secrets that belong to other apps,
+        # and user secrets should have uniform ID behaviour.
+        app_secret = harness.model.app.add_secret({'password': '1234'})
+        unit_secret = harness.model.unit.add_secret({'password': '5678'})
+        remote_secret_id = harness.add_model_secret('database', {'password': 'abcd'})
+        harness.grant_secret(remote_secret_id, 'webapp')
+        remote_secret = harness.model.get_secret(id=remote_secret_id)
+        user_secret_id = harness.add_user_secret({'password': 'efgh'})
+        harness.grant_secret(user_secret_id, 'webapp')
+        user_secret = harness.model.get_secret(id=user_secret_id)
+
+        # Ensure that all three variants of the secret ID can be used to
+        # retrieve the secret from Harness.
+        for secret in (app_secret, unit_secret, remote_secret, user_secret):
+            id_only = secret.unique_identifier
+            id_with_prefix = f'secret:{id_only}'
+            id_with_model = f'secret://{harness.model.uuid}/{id_only}'
+            for uri in (id_only, id_with_prefix, id_with_model):
+                assert harness.model.get_secret(id=uri).get_content() == secret.get_content()
+
 
 class EventRecorder(ops.CharmBase):
     def __init__(self, framework: ops.Framework):