Repository: canonical/operator

Issue:
Warn charms that use collect-metrics, series-upgrade that they are not supported in Juju 4
Juju 4 will not support the collect-metrics event. ops needs to continue supporting it as long as ops supports Juju 3.x, but we can help alert charmers to the need to move away from it.

* We should generate a DeprecationWarning when calling an observer of a collect-metrics event (or when a CollectMetricsEvent object is initialised, etc), that warns the charmer that Juju 4 will not emit these events and gives them info (e.g. a doc link) that helps them move to an alternative. This will end up in debug-log every 5 minutes.

The same applies to the pre-series-upgrade and post-series-upgrade events. A DeprecationWarning may not make sense here, given the circumstances of when these events occur - however, we can make sure it's clear in the documentation.

For what it's worth, I can't find any operator charms that observe collect-metrics, or pre-series-upgrade, and only one that observes post-series-upgrade.

Discussion:
@benhoyt: Thanks. DeprecationWarning sounds good. Initially I was thinking only the [first one](https://docs.python.org/3/library/warnings.html#warning-filter) would be surfaced, but of course it's a new Python instance each time, so yeah, every 5 minutes. That's probably okay if someone is doing this.

I think given the very small usage (if any?) that we've seen of collect-metrics, so I'd say let's not worry about ops.jujuversion.

Solution patch:
diff --git a/.github/update-published-charms-tests-workflow.py b/.github/update-published-charms-tests-workflow.py
new file mode 100755
index 000000000..d604d6a61
--- /dev/null
+++ b/.github/update-published-charms-tests-workflow.py
@@ -0,0 +1,132 @@
+#! /usr/bin/env python
+
+# Copyright 2024 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Update a GitHub workload that runs `tox -e unit` on all published charms.
+
+Charms that are not hosted on GitHub are skipped, as well as any charms where
+the source URL could not be found.
+"""
+
+import json
+import logging
+import pathlib
+import re
+import typing
+import urllib.error
+import urllib.parse
+import urllib.request
+
+logger = logging.getLogger(__name__)
+
+
+URL_BASE = 'https://api.charmhub.io/v2/charms/info'
+WORKFLOW = pathlib.Path(__file__).parent / 'workflows' / 'published-charms-tests.yaml'
+
+SKIP = {
+    # Handled by db-charm-tests.yaml
+    'postgresql-operator',
+    'postgresql-k8s-operator',
+    'mysql-operator',
+    'mysql-k8s-operator',
+    # Handled by hello-charm-tests.yaml
+    'hello-kubecon',  # Not in the canonical org anyway (jnsgruk).
+    'hello-juju-charm',  # Not in the canonical org anyway (juju).
+    # Handled by observability-charms-tests.yaml
+    'alertmanager-k8s-operator',
+    'prometheus-k8s-operator',
+    'grafana-k8s-operator',
+    # This has a redirect, which is too complicated to handle for now.
+    'bundle-jupyter',
+    # The charms are in a subfolder, which this can't handle yet.
+    'jimm',
+    'notebook-operators',
+    # Not ops.
+    'charm-prometheus-libvirt-exporter',
+    'juju-dashboard',
+    'charm-openstack-service-checks',
+}
+
+
+def packages():
+    """Get the list of published charms from Charmhub."""
+    logger.info('Fetching the list of published charms')
+    url = 'https://charmhub.io/packages.json'
+    with urllib.request.urlopen(url, timeout=120) as response:  # noqa: S310 (unsafe URL)
+        data = response.read().decode()
+        packages = json.loads(data)['packages']
+    return packages
+
+
+def get_source_url(charm: str):
+    """Get the source URL for a charm."""
+    logger.info("Looking for a 'source' URL for %s", charm)
+    try:
+        with urllib.request.urlopen(  # noqa: S310 (unsafe URL)
+            f'{URL_BASE}/{charm}?fields=result.links', timeout=30
+        ) as response:
+            data = json.loads(response.read().decode())
+            return data['result']['links']['source'][0]
+    except (urllib.error.HTTPError, KeyError):
+        pass
+    logger.info("Looking for a 'bugs-url' URL for %s", charm)
+    try:
+        with urllib.request.urlopen(  # noqa: S310 (unsafe URL)
+            f'{URL_BASE}/{charm}?fields=result.bugs-url', timeout=30
+        ) as response:
+            data = json.loads(response.read().decode())
+            return data['result']['bugs-url']
+    except (urllib.error.HTTPError, KeyError):
+        pass
+    logger.warning('Could not find a source URL for %s', charm)
+    return None
+
+
+def url_to_charm_name(url: typing.Optional[str]):
+    """Get the charm name from a URL."""
+    if not url:
+        return None
+    parsed = urllib.parse.urlparse(url)
+    if parsed.netloc != 'github.com':
+        logger.info('URL %s is not a GitHub URL', url)
+        return None
+    if not parsed.path.startswith('/canonical'):
+        # TODO: Maybe we can include some of these anyway?
+        # 'juju-solutions' and 'charmed-kubernetes' seem viable, for example.
+        logger.info('URL %s is not a Canonical charm', url)
+        return None
+    try:
+        return urllib.parse.urlparse(url).path.split('/')[2]
+    except IndexError:
+        logger.warning('Could not get charm name from URL %s', url)
+        return None
+
+
+def main():
+    """Update the workflow file."""
+    logging.basicConfig(level=logging.INFO)
+    charms = [url_to_charm_name(get_source_url(package['name'])) for package in packages()]
+    charms = [charm for charm in charms if charm and charm not in SKIP]
+    charms.sort()
+    with WORKFLOW.open('r') as f:
+        workflow = f.read()
+    repos = '\n'.join(f'          - charm-repo: canonical/{charm}' for charm in charms)
+    workflow = re.sub(r'(\s{10}- charm-repo: \S+\n)+', repos + '\n', workflow, count=1)
+    with WORKFLOW.open('w') as f:
+        f.write(workflow)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/.github/workflows/db-charm-tests.yaml b/.github/workflows/db-charm-tests.yaml
index acef622d9..0ddfa2343 100644
--- a/.github/workflows/db-charm-tests.yaml
+++ b/.github/workflows/db-charm-tests.yaml
@@ -15,13 +15,15 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/postgresql-operator
-            commit: e59139a097bd407b5e785daf8193b777cc691af9  # 2024-08-26T10:45:00Z
+            commit: 01cfd3e268ecd5024ded6bb6aede43ff85417b78  # 2024-09-11T16:07:20Z
           - charm-repo: canonical/postgresql-k8s-operator
-            commit: beed68055e33191721896a3b8e28274a99e7de7b  # 2024-08-26T16:47:32Z
-          - charm-repo: canonical/mysql-operator
-            commit: 25a8a79680f3fbf3c36e9e1d8c20a852f54129c1  # 2024-08-26T13:48:51Z
+            commit: f203e4d9d3ab9856b5dc0786217fce8428384e90  # 2024-09-16T12:00:58Z
+          # Waiting for an upstream PR:
+          # https://github.com/canonical/mysql-operator/pull/519
+          #- charm-repo: canonical/mysql-operator
+          #  commit: 4ff2dc867a1586d43a93d0017cc648d591fc9886  # 2024-09-13T15:38:12Z
           - charm-repo: canonical/mysql-k8s-operator
-            commit: 52df6266067d7233b7a067f7a967170b4096dba8  # rev183 rev182 2024-08-26T13:44:33Z
+            commit: 2009919583eddad22656b9b4ebe95adbcd518c0f  # rev199 rev198 2024-09-16T18:14:16Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml
index 09aa1b083..af1879afb 100644
--- a/.github/workflows/observability-charm-tests.yaml
+++ b/.github/workflows/observability-charm-tests.yaml
@@ -17,10 +17,14 @@ jobs:
         include:
           - charm-repo: canonical/alertmanager-k8s-operator
             commit: 4b2f567c43bc67e9d1fe24f018ba186dcd0396ed  # 2024-08-28T11:32:28Z
-          - charm-repo: canonical/prometheus-k8s-operator
-            commit: 27025e5d840ec09331608b398cf4e49e5fe256e9  # 2024-08-28T11:32:32Z
-          - charm-repo: canonical/grafana-k8s-operator
-            commit: 1c80f746f8edeae6fd23ddf31eed45f5b88c06b4  # rev118 2024-08-12T14:02:32Z
+          # Waiting for an upstream PR:
+          # https://github.com/canonical/prometheus-k8s-operator/pull/639
+          #- charm-repo: canonical/prometheus-k8s-operator
+          #  commit: 7518ec8343941ca91248614fd8f2d50fdd9e068c  # rev135 2024-09-06T12:10:02Z
+          # Waiting for an upstream PR:
+          # https://github.com/canonical/grafana-k8s-operator/pull/347
+          #- charm-repo: canonical/grafana-k8s-operator
+          #  commit: 1bd9e7fd96a9f5a91443e5932667c58a92c1ce5e  # 2024-09-03T14:57:42Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/published-charms-tests.yaml b/.github/workflows/published-charms-tests.yaml
new file mode 100644
index 000000000..d7430de06
--- /dev/null
+++ b/.github/workflows/published-charms-tests.yaml
@@ -0,0 +1,131 @@
+# To update the list of charms included here, run:
+# python .github/update-published-charms-tests-workflow.py
+
+name: Broad Charm Compatibility Tests
+
+on:
+  schedule:
+    - cron: '0 1 25 * *'
+  workflow_dispatch:
+
+jobs:
+  charm-tests:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - charm-repo: canonical/content-cache-k8s-operator
+          - charm-repo: canonical/data-platform-libs
+          - charm-repo: canonical/dex-auth-operator
+          - charm-repo: canonical/discourse-k8s-operator
+          - charm-repo: canonical/grafana-agent-k8s-operator
+          - charm-repo: canonical/hardware-observer-operator
+          - charm-repo: canonical/identity-platform-login-ui-operator
+          - charm-repo: canonical/indico-operator
+          - charm-repo: canonical/jenkins-agent-k8s-operator
+          - charm-repo: canonical/jenkins-agent-operator
+          - charm-repo: canonical/kafka-operator
+          - charm-repo: canonical/livepatch-k8s-operator
+          - charm-repo: canonical/loki-k8s-operator
+          - charm-repo: canonical/manual-tls-certificates-operator
+          - charm-repo: canonical/mongodb-operator
+          - charm-repo: canonical/mysql-router-k8s-operator
+          - charm-repo: canonical/namecheap-lego-k8s-operator
+          - charm-repo: canonical/nginx-ingress-integrator-operator
+          - charm-repo: canonical/oathkeeper-operator
+          - charm-repo: canonical/oauth2-proxy-k8s-operator
+          - charm-repo: canonical/openfga-operator
+          - charm-repo: canonical/pgbouncer-k8s-operator
+          - charm-repo: canonical/ranger-k8s-operator
+          - charm-repo: canonical/route53-lego-k8s-operator
+          - charm-repo: canonical/s3-integrator
+          - charm-repo: canonical/saml-integrator-operator
+          - charm-repo: canonical/seldon-core-operator
+          - charm-repo: canonical/self-signed-certificates-operator
+          - charm-repo: canonical/smtp-integrator-operator
+          - charm-repo: canonical/superset-k8s-operator
+          - charm-repo: canonical/temporal-admin-k8s-operator
+          - charm-repo: canonical/temporal-k8s-operator
+          - charm-repo: canonical/temporal-ui-k8s-operator
+          - charm-repo: canonical/temporal-worker-k8s-operator
+          - charm-repo: canonical/traefik-k8s-operator
+          - charm-repo: canonical/trino-k8s-operator
+          - charm-repo: canonical/wordpress-k8s-operator
+          - charm-repo: canonical/zookeeper-operator
+    steps:
+      - name: Checkout the ${{ matrix.charm-repo }} repository
+        uses: actions/checkout@v4
+        with:
+          repository: ${{ matrix.charm-repo }}
+
+      - name: Checkout the operator repository
+        uses: actions/checkout@v4
+        with:
+          path: myops
+
+      - name: Install patch dependencies
+        run: pip install poetry~=1.6
+
+      - name: Update 'ops' dependency in test charm to latest
+        run: |
+          rm -rf myops/test
+          if [ -e "test-requirements.txt" ]; then
+            sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" test-requirements.txt
+            echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> test-requirements.txt
+          fi
+          if [ -e "requirements-charmcraft.txt" ]; then
+            sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" requirements-charmcraft.txt
+            echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> requirements-charmcraft.txt
+          fi
+          if [ -e "requirements.txt" ]; then
+            sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" requirements.txt
+            echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> requirements.txt
+          elif [ -e "poetry.lock" ]; then
+            sed -i -e "s/^ops[ ><=].*/ops = {path = \"myops\"}/" pyproject.toml
+            poetry lock --no-update
+          else
+            echo "Error: No requirements.txt or poetry.lock file found"
+            exit 1
+          fi
+
+      - name: Install dependencies
+        run: pip install tox~=4.2
+
+      - name: Run the charm's unit tests
+        run: tox -vve unit
+
+  charmcraft-profile-tests:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - profile: machine
+          - profile: kubernetes
+          - profile: simple
+    steps:
+      - name: Install charmcraft
+        run: sudo snap install charmcraft --classic
+
+      - name: Charmcraft init
+        run: charmcraft init --profile=${{ matrix.profile }} --author=charm-tech
+
+      - name: Checkout the operator repository
+        uses: actions/checkout@v4
+        with:
+          path: myops
+
+      - name: Update 'ops' dependency in test charm to latest
+        run: |
+          rm -rf myops/test
+          if [ -e "requirements.txt" ]; then
+            sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" requirements.txt
+            echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> requirements.txt
+          fi
+
+      - name: Install dependencies
+        run: pip install tox~=4.2
+
+      - name: Run the charm's unit tests
+        run: tox -vve unit
diff --git a/docs/custom_conf.py b/docs/custom_conf.py
index b1ccbc019..f567b91ec 100644
--- a/docs/custom_conf.py
+++ b/docs/custom_conf.py
@@ -326,6 +326,8 @@ def _compute_navigation_tree(context):
     ('py:class', '_TextOrBinaryIO'),
     ('py:class', '_WarningDict'),
     ('py:class', '_Writeable'),
+    ('py:obj', 'ops._private.harness.CharmType'),
+    ('py:class', 'ops._private.harness.CharmType'),
     ('py:class', 'ops.charm._ContainerBaseDict'),
     ('py:class', 'ops.model._AddressDict'),
     ('py:class', 'ops.model._GenericLazyMapping'),
diff --git a/docs/index.rst b/docs/index.rst
index af4de65ca..84f58b3f8 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -34,7 +34,11 @@ ops.pebble module
 ops.testing module
 ==================
 
-.. automodule:: ops.testing
+.. autoclass:: ops.testing.ActionFailed
+.. autoclass:: ops.testing.ActionOutput
+.. autoclass:: ops.testing.ExecArgs
+.. autoclass:: ops.testing.ExecResult
+.. autoclass:: ops.testing.Harness
 
 
 Indices
diff --git a/ops/__init__.py b/ops/__init__.py
index 19679cfec..3108647db 100644
--- a/ops/__init__.py
+++ b/ops/__init__.py
@@ -162,6 +162,7 @@
     'SecretRotate',
     'ServiceInfoMapping',
     'StatusBase',
+    'StatusName',
     'Storage',
     'StorageMapping',
     'TooManyRelatedAppsError',
@@ -309,6 +310,7 @@
     SecretRotate,
     ServiceInfoMapping,
     StatusBase,
+    StatusName,
     Storage,
     StorageMapping,
     TooManyRelatedAppsError,
diff --git a/ops/_private/harness.py b/ops/_private/harness.py
new file mode 100644
index 000000000..09334406e
--- /dev/null
+++ b/ops/_private/harness.py
@@ -0,0 +1,3835 @@
+# Copyright 2021 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Infrastructure to build unit tests for charms using the ops library."""
+
+import dataclasses
+import datetime
+import fnmatch
+import http
+import inspect
+import io
+import ipaddress
+import os
+import pathlib
+import random
+import re
+import shutil
+import signal
+import tempfile
+import typing
+import uuid
+import warnings
+from contextlib import contextmanager
+from io import BytesIO, IOBase, StringIO
+from textwrap import dedent
+from typing import (
+    Any,
+    AnyStr,
+    BinaryIO,
+    Callable,
+    Dict,
+    Generic,
+    Iterable,
+    List,
+    Literal,
+    Mapping,
+    Optional,
+    Sequence,
+    Set,
+    TextIO,
+    Tuple,
+    Type,
+    TypedDict,
+    TypeVar,
+    Union,
+    cast,
+)
+
+from ops import charm, framework, model, pebble, storage
+from ops._private import yaml
+from ops.charm import CharmBase, CharmMeta, RelationRole
+from ops.jujucontext import _JujuContext
+from ops.model import Container, RelationNotFoundError, StatusName, _NetworkDict
+from ops.pebble import ExecProcess
+
+ReadableBuffer = Union[bytes, str, StringIO, BytesIO, BinaryIO]
+_StringOrPath = Union[str, pathlib.PurePosixPath, pathlib.Path]
+_FileKwargs = TypedDict(
+    '_FileKwargs',
+    {
+        'permissions': Optional[int],
+        'last_modified': datetime.datetime,
+        'user_id': Optional[int],
+        'user': Optional[str],
+        'group_id': Optional[int],
+        'group': Optional[str],
+    },
+)
+
+_RelationEntities = TypedDict('_RelationEntities', {'app': str, 'units': List[str]})
+
+_RawStatus = TypedDict(
+    '_RawStatus',
+    {
+        'status': StatusName,
+        'message': str,
+    },
+)
+_ConfigOption = TypedDict(
+    '_ConfigOption',
+    {
+        'type': Literal['string', 'int', 'float', 'boolean', 'secret'],
+        'description': str,
+        'default': Union[str, int, float, bool],
+    },
+)
+_RawConfig = TypedDict('_RawConfig', {'options': Dict[str, _ConfigOption]})
+
+
+# YAMLStringOrFile is something like metadata.yaml or actions.yaml. You can
+# pass in a file-like object or the string directly.
+YAMLStringOrFile = Union[str, TextIO]
+
+
+# An instance of an Application or Unit, or the name of either.
+# This is done here to avoid a scoping issue with the `model` property
+# of the Harness class below.
+AppUnitOrName = Union[str, model.Application, model.Unit]
+
+
+# CharmType represents user charms that are derived from CharmBase.
+CharmType = TypeVar('CharmType', bound=charm.CharmBase)
+
+
+@dataclasses.dataclass
+class ExecArgs:
+    """Represent arguments captured from the :meth:`ops.Container.exec` method call.
+
+    These arguments will be passed to the :meth:`Harness.handle_exec` handler function.
+    See :meth:`ops.pebble.Client.exec` for documentation of properties.
+    """
+
+    command: List[str]
+    environment: Dict[str, str]
+    working_dir: Optional[str]
+    timeout: Optional[float]
+    user_id: Optional[int]
+    user: Optional[str]
+    group_id: Optional[int]
+    group: Optional[str]
+    stdin: Optional[Union[str, bytes]]
+    encoding: Optional[str]
+    combine_stderr: bool
+
+
+@dataclasses.dataclass
+class ExecResult:
+    """Represents the result of a simulated process execution.
+
+    This class is typically used to return the output and exit code from the
+    :meth:`Harness.handle_exec` result or handler function.
+    """
+
+    exit_code: int = 0
+    stdout: Union[str, bytes] = b''
+    stderr: Union[str, bytes] = b''
+
+
+ExecHandler = Callable[[ExecArgs], Union[None, ExecResult]]
+
+
+@dataclasses.dataclass(frozen=True)
+class ActionOutput:
+    """Contains the logs and results from a :meth:`Harness.run_action` call."""
+
+    logs: List[str]
+    """Messages generated by the Charm using :meth:`ops.ActionEvent.log`."""
+
+    results: Dict[str, Any]
+    """The action's results, as set or updated by :meth:`ops.ActionEvent.set_results`."""
+
+
+class ActionFailed(Exception):  # noqa
+    """Raised when :code:`event.fail()` is called during a :meth:`Harness.run_action` call."""
+
+    message: str
+    """Optional details of the failure, as provided by :meth:`ops.ActionEvent.fail`."""
+
+    output: ActionOutput
+    """Any logs and results set by the Charm."""
+
+    def __init__(self, message: str, output: ActionOutput):
+        self.message = message
+        self.output = output
+
+    def __str__(self):
+        if self.message:
+            return self.message
+        return 'Event handler called `fail()` with no additional details.'
+
+
+@dataclasses.dataclass()
+class _RunningAction:
+    name: str
+    output: ActionOutput
+    parameters: Dict[str, Any]
+    failure_message: Optional[str] = None
+
+
+# noinspection PyProtectedMember
+class Harness(Generic[CharmType]):
+    """This class represents a way to build up the model that will drive a test suite.
+
+    The model created is from the viewpoint of the charm that is being tested.
+
+    Always call ``harness.cleanup()`` after creating a :class:`Harness`::
+
+        @pytest.fixture()
+        def harness():
+            harness = Harness(MyCharm)
+            yield harness
+            harness.cleanup()
+
+    Below is an example test using :meth:`begin_with_initial_hooks` that ensures
+    the charm responds correctly to config changes (the parameter ``harness`` in the
+    test function is a pytest fixture that does setup/teardown, see :class:`Harness`)::
+
+        def test_foo(harness):
+            # Instantiate the charm and trigger events that Juju would on startup
+            harness.begin_with_initial_hooks()
+
+            # Update charm config and trigger config-changed
+            harness.update_config({'log_level': 'warn'})
+
+            # Check that charm properly handled config-changed, for example,
+            # the charm added the correct Pebble layer
+            plan = harness.get_container_pebble_plan('prometheus')
+            assert '--log.level=warn' in plan.services['prometheus'].command
+
+    To set up the model without triggering events (or calling charm code), perform the
+    harness actions before calling :meth:`begin`. Below is an example that adds a
+    relation before calling ``begin``, and then updates config to trigger the
+    ``config-changed`` event in the charm (the parameter ``harness`` in the test function
+    is a pytest fixture that does setup/teardown, see :class:`Harness`)::
+
+        def test_bar(harness):
+            # Set up model before "begin" (no events triggered)
+            harness.set_leader(True)
+            harness.add_relation('db', 'postgresql', unit_data={'key': 'val'})
+
+            # Now instantiate the charm to start triggering events as the model changes
+            harness.begin()
+            harness.update_config({'some': 'config'})
+
+            # Check that charm has properly handled config-changed, for example,
+            # has written the app's config file
+            root = harness.get_filesystem_root('container')
+            assert (root / 'etc' / 'app.conf').exists()
+
+    Args:
+        charm_cls: The Charm class to test.
+        meta: A string or file-like object containing the contents of
+            ``metadata.yaml``. If not supplied, we will look for a ``metadata.yaml`` file in the
+            parent directory of the Charm, and if not found fall back to a trivial
+            ``name: test-charm`` metadata.
+        actions: A string or file-like object containing the contents of
+            ``actions.yaml``. If not supplied, we will look for an ``actions.yaml`` file in the
+            parent directory of the Charm.
+        config: A string or file-like object containing the contents of
+            ``config.yaml``. If not supplied, we will look for a ``config.yaml`` file in the
+            parent directory of the Charm.
+    """
+
+    def __init__(
+        self,
+        charm_cls: Type[CharmType],
+        *,
+        meta: Optional[YAMLStringOrFile] = None,
+        actions: Optional[YAMLStringOrFile] = None,
+        config: Optional[YAMLStringOrFile] = None,
+    ):
+        context_environ = os.environ.copy()
+        if 'JUJU_VERSION' not in context_environ:
+            context_environ['JUJU_VERSION'] = '0.0.0'
+        self._juju_context = _JujuContext.from_dict(context_environ)
+        self._charm_cls = charm_cls
+        self._charm: Optional[CharmType] = None
+        self._charm_dir = 'no-disk-path'  # this may be updated by _create_meta
+        self._meta = self._create_meta(meta, actions)
+        self._unit_name: str = f'{self._meta.name}/0'
+        self._hooks_enabled: bool = True
+        self._relation_id_counter: int = 0
+        self._action_id_counter: int = 0
+        config_ = self._get_config(config)
+        self._backend = _TestingModelBackend(
+            self._unit_name, self._meta, config_, self._juju_context
+        )
+        self._model = model.Model(self._meta, self._backend)
+        self._storage = storage.SQLiteStorage(':memory:')
+        self._framework = framework.Framework(
+            self._storage,
+            self._charm_dir,
+            self._meta,
+            self._model,
+            juju_debug_at=self._juju_context.debug_at,
+        )
+
+    def _event_context(self, event_name: str):
+        """Configures the Harness to behave as if an event hook were running.
+
+        This means that the Harness will perform strict access control of relation data.
+
+        Example usage:
+
+        # this is how we test that attempting to write a remote app's
+        # databag will raise RelationDataError.
+        >>> with harness._event_context('foo'):
+        >>>     with pytest.raises(ops.model.RelationDataError):
+        >>>         my_relation.data[remote_app]['foo'] = 'bar'
+
+        # this is how we test with 'realistic conditions' how an event handler behaves
+        # when we call it directly -- i.e. without going through harness.add_relation
+        >>> def test_foo():
+        >>>     class MyCharm:
+        >>>         ...
+        >>>         def event_handler(self, event):
+        >>>             # this is expected to raise an exception
+        >>>             event.relation.data[event.relation.app]['foo'] = 'bar'
+        >>>
+        >>>     harness = Harness(MyCharm)
+        >>>     event = MagicMock()
+        >>>     event.relation = harness.charm.model.relations[0]
+        >>>
+        >>>     with harness._event_context('my_relation_joined'):
+        >>>         with pytest.raises(ops.model.RelationDataError):
+        >>>             harness.charm.event_handler(event)
+
+
+        If event_name == '', conversely, the Harness will believe that no hook
+        is running, allowing temporary unrestricted access to read/write a relation's
+        databags even from inside an event handler.
+        >>> def test_foo():
+        >>>     class MyCharm:
+        >>>         ...
+        >>>         def event_handler(self, event):
+        >>>             # this is expected to raise an exception since we're not leader
+        >>>             event.relation.data[self.app]['foo'] = 'bar'
+        >>>
+        >>>     harness = Harness(MyCharm)
+        >>>     event = MagicMock()
+        >>>     event.relation = harness.charm.model.relations[0]
+        >>>
+        >>>     with harness._event_context('my_relation_joined'):
+        >>>         harness.charm.event_handler(event)
+
+        """
+        return self._framework._event_context(event_name)
+
+    def set_can_connect(self, container: Union[str, model.Container], val: bool):
+        """Change the simulated connection status of a container's underlying Pebble client.
+
+        After calling this, :meth:`ops.Container.can_connect` will return val.
+        """
+        if isinstance(container, str):
+            container = self.model.unit.get_container(container)
+        self._backend._set_can_connect(container._pebble, val)
+
+    @property
+    def charm(self) -> CharmType:
+        """Return the instance of the charm class that was passed to ``__init__``.
+
+        Note that the Charm is not instantiated until :meth:`.begin()` is called.
+        Until then, attempting to access this property will raise an exception.
+        """
+        if self._charm is None:
+            raise RuntimeError(
+                'The charm instance is not available yet. Call Harness.begin() first.'
+            )
+        return self._charm
+
+    @property
+    def model(self) -> model.Model:
+        """Return the :class:`~ops.model.Model` that is being driven by this Harness."""
+        return self._model
+
+    @property
+    def framework(self) -> framework.Framework:
+        """Return the Framework that is being driven by this Harness."""
+        return self._framework
+
+    def begin(self) -> None:
+        """Instantiate the Charm and start handling events.
+
+        Before calling :meth:`begin`, there is no Charm instance, so changes to the Model won't
+        emit events. Call :meth:`.begin` for :attr:`.charm` to be valid.
+
+        Should only be called once.
+        """
+        if self._charm is not None:
+            raise RuntimeError('cannot call the begin method on the harness more than once')
+
+        # The Framework adds attributes to class objects for events, etc. As such, we can't re-use
+        # the original class against multiple Frameworks. So create a locally defined class
+        # and register it.
+        # TODO: jam 2020-03-16 We are looking to changes this to Instance attributes instead of
+        #       Class attributes which should clean up this ugliness. The API can stay the same
+        class TestEvents(self._charm_cls.on.__class__):
+            pass
+
+        TestEvents.__name__ = self._charm_cls.on.__class__.__name__
+
+        class TestCharm(self._charm_cls):
+            on = TestEvents()
+
+        # Note: jam 2020-03-01 This is so that errors in testing say MyCharm has no attribute foo,
+        # rather than TestCharm has no attribute foo.
+        TestCharm.__name__ = self._charm_cls.__name__
+        self._charm = TestCharm(self._framework)  # type: ignore
+
+    def begin_with_initial_hooks(self) -> None:
+        """Fire the same hooks that Juju would fire at startup.
+
+        This triggers install, relation-created, config-changed, start, pebble-ready (for any
+        containers), and any relation-joined hooks based on what relations have been added before
+        begin was called. Note that all of these are fired before returning control
+        to the test suite, so to introspect what happens at each step, fire them directly
+        (for example, ``Charm.on.install.emit()``).
+
+        To use this with all the normal hooks, instantiate the harness, setup any relations that
+        should be active when the charm starts, and then call this method. This method will
+        automatically create and add peer relations that are specified in metadata.yaml.
+
+        If the charm metadata specifies containers, this sets can_connect to True for all
+        containers (in addition to triggering pebble-ready for each).
+
+        Example::
+
+            harness = Harness(MyCharm)
+            # Do initial setup here
+            # Add storage if needed before begin_with_initial_hooks() is called
+            storage_ids = harness.add_storage('data', count=1)[0]
+            storage_id = storage_id[0] # we only added one storage instance
+            harness.add_relation('db', 'postgresql', unit_data={'key': 'val'})
+            harness.set_leader(True)
+            harness.update_config({'initial': 'config'})
+            harness.begin_with_initial_hooks()
+            # This will cause
+            # install, db-relation-created('postgresql'), leader-elected, config-changed, start
+            # db-relation-joined('postgresql/0'), db-relation-changed('postgresql/0')
+            # To be fired.
+        """
+        self.begin()
+
+        charm = cast(CharmBase, self._charm)
+        # Checking if disks have been added
+        # storage-attached events happen before install
+        for storage_name in self._meta.storages:
+            for storage_index in self._backend.storage_list(storage_name, include_detached=True):
+                s = model.Storage(storage_name, storage_index, self._backend)
+                if self._backend._storage_is_attached(storage_name, storage_index):
+                    # Attaching was done already, but we still need the event to be emitted.
+                    self.charm.on[storage_name].storage_attached.emit(s)
+                else:
+                    self.attach_storage(s.full_id)
+        # Storage done, emit install event
+        charm.on.install.emit()
+
+        # Juju itself iterates what relation to fire based on a map[int]relation, so it doesn't
+        # guarantee a stable ordering between relation events. It *does* give a stable ordering
+        # of joined units for a given relation.
+        items = list(self._meta.relations.items())
+        random.shuffle(items)
+        this_app_name = self._meta.name
+        for relname, rel_meta in items:
+            if rel_meta.role == RelationRole.peer:
+                # If the user has directly added a relation, leave it be, but otherwise ensure
+                # that peer relations are always established at before leader-elected.
+                rel_ids = self._backend._relation_ids_map.get(relname)
+                if rel_ids is None:
+                    self.add_relation(relname, self._meta.name)
+                else:
+                    random.shuffle(rel_ids)
+                    for rel_id in rel_ids:
+                        self._emit_relation_created(relname, rel_id, this_app_name)
+            else:
+                rel_ids = self._backend._relation_ids_map.get(relname, [])
+                random.shuffle(rel_ids)
+                for rel_id in rel_ids:
+                    app_name = self._backend._relation_app_and_units[rel_id]['app']
+                    self._emit_relation_created(relname, rel_id, app_name)
+        if self._backend._is_leader:
+            charm.on.leader_elected.emit()
+        else:
+            charm.on.leader_settings_changed.emit()
+
+        charm.on.config_changed.emit()
+
+        charm.on.start.emit()
+
+        # Set can_connect and fire pebble-ready for any containers.
+        for container_name in self._meta.containers:
+            self.container_pebble_ready(container_name)
+
+        # If the initial hooks do not set a unit status, the Juju controller will switch
+        # the unit status from "Maintenance" to "Unknown". See gh#726
+        post_setup_sts = self._backend.status_get()
+        if post_setup_sts.get('status') == 'maintenance' and not post_setup_sts.get('message'):
+            self._backend._unit_status = {'status': 'unknown', 'message': ''}
+        all_ids = list(self._backend._relation_names.items())
+        random.shuffle(all_ids)
+        for rel_id, rel_name in all_ids:
+            rel_app_and_units = self._backend._relation_app_and_units[rel_id]
+            app_name = rel_app_and_units['app']
+            # Note: Juju *does* fire relation events for a given relation in the sorted order of
+            # the unit names. It also always fires relation-changed immediately after
+            # relation-joined for the same unit.
+            # Juju only fires relation-changed (app) if there is data for the related application
+            relation = self._model.get_relation(rel_name, rel_id)
+            if self._backend._relation_data_raw[rel_id].get(app_name):
+                app = self._model.get_app(app_name)
+                charm.on[rel_name].relation_changed.emit(relation, app, None)
+            for unit_name in sorted(rel_app_and_units['units']):
+                remote_unit = self._model.get_unit(unit_name)
+                charm.on[rel_name].relation_joined.emit(relation, remote_unit.app, remote_unit)
+                charm.on[rel_name].relation_changed.emit(relation, remote_unit.app, remote_unit)
+
+    def cleanup(self) -> None:
+        """Called by the test infrastructure to clean up any temporary directories/files/etc.
+
+        Always call ``self.addCleanup(harness.cleanup)`` after creating a :class:`Harness`.
+        """
+        self._backend._cleanup()
+
+    def _create_meta(
+        self,
+        charm_metadata_yaml: Optional[YAMLStringOrFile],
+        action_metadata_yaml: Optional[YAMLStringOrFile],
+    ) -> CharmMeta:
+        """Create a CharmMeta object.
+
+        Handle the cases where a user doesn't supply explicit metadata snippets.
+        This will try to load metadata from ``<charm_dir>/charmcraft.yaml`` first, then
+        ``<charm_dir>/metadata.yaml`` if charmcraft.yaml does not include metadata,
+        and ``<charm_dir>/actions.yaml`` if charmcraft.yaml does not include actions.
+        """
+        try:
+            filename = inspect.getfile(self._charm_cls)
+        except OSError:
+            charm_dir = None
+        else:
+            charm_dir = pathlib.Path(filename).parents[1]
+
+        charm_metadata: Optional[Dict[str, Any]] = None
+        charmcraft_metadata: Optional[Dict[str, Any]] = None
+        if charm_dir:
+            # Check charmcraft.yaml and load it if it exists
+            charmcraft_meta = charm_dir / 'charmcraft.yaml'
+            if charmcraft_meta.is_file():
+                self._charm_dir = charm_dir
+                charmcraft_metadata = yaml.safe_load(charmcraft_meta.read_text())
+
+        # Load metadata from parameters if provided
+        if charm_metadata_yaml is not None:
+            if isinstance(charm_metadata_yaml, str):
+                charm_metadata_yaml = dedent(charm_metadata_yaml)
+            charm_metadata = yaml.safe_load(charm_metadata_yaml)
+        else:
+            # Check charmcraft.yaml for metadata if no metadata is provided
+            if charmcraft_metadata is not None:
+                meta_keys = ['name', 'summary', 'description']
+                if any(key in charmcraft_metadata for key in meta_keys):
+                    # Unrelated keys in the charmcraft.yaml file will be ignored.
+                    charm_metadata = charmcraft_metadata
+
+            # Still no metadata, check metadata.yaml
+            if charm_dir and charm_metadata is None:
+                metadata_path = charm_dir / 'metadata.yaml'
+                if metadata_path.is_file():
+                    charm_metadata = yaml.safe_load(metadata_path.read_text())
+                    self._charm_dir = charm_dir
+
+        # Use default metadata if metadata is not found
+        if charm_metadata is None:
+            charm_metadata = {'name': 'test-charm'}
+
+        action_metadata: Optional[Dict[str, Any]] = None
+        # Load actions from parameters if provided
+        if action_metadata_yaml is not None:
+            if isinstance(action_metadata_yaml, str):
+                action_metadata_yaml = dedent(action_metadata_yaml)
+            action_metadata = yaml.safe_load(action_metadata_yaml)
+        else:
+            # Check charmcraft.yaml for actions if no actions are provided
+            if charmcraft_metadata is not None and 'actions' in charmcraft_metadata:
+                action_metadata = charmcraft_metadata['actions']
+
+            # Still no actions, check actions.yaml
+            if charm_dir and action_metadata is None:
+                actions_path = charm_dir / 'actions.yaml'
+                if actions_path.is_file():
+                    action_metadata = yaml.safe_load(actions_path.read_text())
+                    self._charm_dir = charm_dir
+
+        return CharmMeta(charm_metadata, action_metadata)
+
+    def _get_config(self, charm_config_yaml: Optional['YAMLStringOrFile']):
+        """If the user passed a config to Harness, use it.
+
+        Otherwise try to load config from ``<charm_dir>/charmcraft.yaml`` first, then
+        ``<charm_dir>/config.yaml`` if charmcraft.yaml does not include config.
+        """
+        try:
+            filename = inspect.getfile(self._charm_cls)
+        except OSError:
+            charm_dir = None
+        else:
+            charm_dir = pathlib.Path(filename).parents[1]
+        config: Optional[Dict[str, Any]] = None
+
+        # Load config from parameters if provided
+        if charm_config_yaml is not None:
+            if isinstance(charm_config_yaml, str):
+                charm_config_yaml = dedent(charm_config_yaml)
+            config = yaml.safe_load(charm_config_yaml)
+        else:
+            if charm_dir:
+                # Check charmcraft.yaml for config if no config is provided
+                charmcraft_meta = charm_dir / 'charmcraft.yaml'
+                if charmcraft_meta.is_file():
+                    charmcraft_metadata: Dict[str, Any] = yaml.safe_load(
+                        charmcraft_meta.read_text()
+                    )
+                    config = charmcraft_metadata.get('config')
+
+                # Still no config, check config.yaml
+                if config is None:
+                    config_path = charm_dir / 'config.yaml'
+                    if config_path.is_file():
+                        config = yaml.safe_load(config_path.read_text())
+                        self._charm_dir = charm_dir
+
+            # Use default config if config is not found
+            if config is None:
+                config = {}
+
+        if not isinstance(config, dict):
+            raise TypeError(config)
+        return cast('_RawConfig', config)
+
+    def add_oci_resource(
+        self, resource_name: str, contents: Optional[Mapping[str, str]] = None
+    ) -> None:
+        """Add OCI resources to the backend.
+
+        This will register an OCI resource and create a temporary file for processing metadata
+        about the resource. A default set of values will be used for all the file contents
+        unless a specific contents dict is provided.
+
+        Args:
+            resource_name: Name of the resource to add custom contents to.
+            contents: Optional custom dict to write for the named resource.
+        """
+        if not contents:
+            contents = {
+                'registrypath': 'registrypath',
+                'username': 'username',
+                'password': 'password',
+            }
+        if resource_name not in self._meta.resources:
+            raise RuntimeError(f'Resource {resource_name} is not a defined resources')
+        if self._meta.resources[resource_name].type != 'oci-image':
+            raise RuntimeError(f'Resource {resource_name} is not an OCI Image')
+
+        as_yaml = yaml.safe_dump(contents)
+        self._backend._resources_map[resource_name] = ('contents.yaml', as_yaml)
+
+    def add_resource(self, resource_name: str, content: AnyStr) -> None:
+        """Add content for a resource to the backend.
+
+        This will register the content, so that a call to ``model.resources.fetch(resource_name)``
+        will return a path to a file containing that content.
+
+        Args:
+            resource_name: The name of the resource being added
+            content: Either string or bytes content, which will be the content of the filename
+                returned by resource-get. If contents is a string, it will be encoded in utf-8
+        """
+        if resource_name not in self._meta.resources:
+            raise RuntimeError(f'Resource {resource_name} is not a defined resource')
+        record = self._meta.resources[resource_name]
+        if record.type != 'file':
+            raise RuntimeError(
+                f'Resource {resource_name} is not a file, but actually {record.type}'
+            )
+        filename = record.filename
+        if filename is None:
+            filename = resource_name
+
+        self._backend._resources_map[resource_name] = (filename, content)
+
+    def populate_oci_resources(self) -> None:
+        """Populate all OCI resources."""
+        for name, data in self._meta.resources.items():
+            if data.type == 'oci-image':
+                self.add_oci_resource(name)
+
+    def disable_hooks(self) -> None:
+        """Stop emitting hook events when the model changes.
+
+        This can be used by developers to stop changes to the model from emitting events that
+        the charm will react to. Call :meth:`.enable_hooks`
+        to re-enable them.
+        """
+        self._hooks_enabled = False
+
+    def enable_hooks(self) -> None:
+        """Re-enable hook events from charm.on when the model is changed.
+
+        By default, hook events are enabled once :meth:`.begin` is called,
+        but if :meth:`.disable_hooks` is used, this method will enable
+        them again.
+        """
+        self._hooks_enabled = True
+
+    @contextmanager
+    def hooks_disabled(self):
+        """A context manager to run code with hooks disabled.
+
+        Example::
+
+            with harness.hooks_disabled():
+                # things in here don't fire events
+                harness.set_leader(True)
+                harness.update_config(unset=['foo', 'bar'])
+            # things here will again fire events
+        """
+        if self._hooks_enabled:
+            self.disable_hooks()
+            try:
+                yield None
+            finally:
+                self.enable_hooks()
+        else:
+            yield None
+
+    def _next_relation_id(self):
+        rel_id = self._relation_id_counter
+        self._relation_id_counter += 1
+        return rel_id
+
+    def add_storage(self, storage_name: str, count: int = 1, *, attach: bool = False) -> List[str]:
+        """Create a new storage device and attach it to this unit.
+
+        To have repeatable tests, each device will be initialized with
+        location set to /[tmpdir]/<storage_name>N, where N is the counter and
+        will be a number from [0,total_num_disks-1].
+
+        The test harness uses symbolic links to imitate storage mounts, which may lead to some
+        inconsistencies compared to the actual charm.
+
+        Args:
+            storage_name: The storage backend name on the Charm
+            count: Number of disks being added
+            attach: True to also attach the storage mount; if :meth:`begin`
+                has been called a True value will also emit storage-attached
+
+        Return:
+            A list of storage IDs, e.g. ["my-storage/1", "my-storage/2"].
+        """
+        if storage_name not in self._meta.storages:
+            raise RuntimeError(
+                f"the key '{storage_name}' is not specified as a storage key in metadata"
+            )
+
+        storage_indices = self._backend.storage_add(storage_name, count)
+
+        ids: List[str] = []
+        for storage_index in storage_indices:
+            s = model.Storage(storage_name, storage_index, self._backend)
+            ids.append(s.full_id)
+            if attach:
+                self.attach_storage(s.full_id)
+        return ids
+
+    def detach_storage(self, storage_id: str) -> None:
+        """Detach a storage device.
+
+        The intent of this function is to simulate a ``juju detach-storage`` call.
+        It will trigger a storage-detaching hook if the storage unit in question exists
+        and is presently marked as attached.
+
+        Note that the Charm is not instantiated until :meth:`begin` is called.
+        Until then, attempting to use this method will raise an exception.
+
+        Args:
+            storage_id: The full storage ID of the storage unit being detached, including the
+                storage key, e.g. my-storage/0.
+        """
+        if self._charm is None:
+            raise RuntimeError('cannot detach storage before Harness is initialised')
+        storage_name, storage_index = storage_id.split('/', 1)
+        storage_index = int(storage_index)
+        storage_attached = self._backend._storage_is_attached(storage_name, storage_index)
+        if storage_attached and self._hooks_enabled:
+            self.charm.on[storage_name].storage_detaching.emit(
+                model.Storage(storage_name, storage_index, self._backend)
+            )
+        self._backend._storage_detach(storage_id)
+
+    def attach_storage(self, storage_id: str) -> None:
+        """Attach a storage device.
+
+        The intent of this function is to simulate a ``juju attach-storage`` call.
+        If called after :meth:`begin` and hooks are not disabled, it will trigger
+        a storage-attached hook if the storage unit in question exists
+        and is presently marked as detached.
+
+        The test harness uses symbolic links to imitate storage mounts, which may lead to some
+        inconsistencies compared to the actual charm.
+
+        Args:
+            storage_id: The full storage ID of the storage unit being attached, including the
+                storage key, e.g. my-storage/0.
+        """
+        if not self._backend._storage_attach(storage_id):
+            return  # storage was already attached
+        if not self._charm or not self._hooks_enabled:
+            return  # don't need to run hook callback
+
+        storage_name, storage_index = storage_id.split('/', 1)
+
+        # Reset associated cached value in the storage mappings.  If we don't do this,
+        # Model._storages won't return Storage objects for subsequently-added storage.
+        self._model._storages._invalidate(storage_name)
+
+        storage_index = int(storage_index)
+        self.charm.on[storage_name].storage_attached.emit(
+            model.Storage(storage_name, storage_index, self._backend)
+        )
+
+    def remove_storage(self, storage_id: str) -> None:
+        """Detach a storage device.
+
+        The intent of this function is to simulate a ``juju remove-storage`` call.
+        It will trigger a storage-detaching hook if the storage unit in question exists
+        and is presently marked as attached.  Then it will remove the storage
+        unit from the testing backend.
+
+        Args:
+            storage_id: The full storage ID of the storage unit being removed, including the
+                storage key, e.g. my-storage/0.
+
+        Raises:
+            RuntimeError: if the storage is not in the metadata.
+        """
+        storage_name, storage_index = storage_id.split('/', 1)
+        storage_index = int(storage_index)
+        if storage_name not in self._meta.storages:
+            raise RuntimeError(
+                f"the key '{storage_name}' is not specified as a storage key in metadata"
+            )
+        is_attached = self._backend._storage_is_attached(storage_name, storage_index)
+        if self._charm is not None and self._hooks_enabled and is_attached:
+            self.charm.on[storage_name].storage_detaching.emit(
+                model.Storage(storage_name, storage_index, self._backend)
+            )
+        self._backend._storage_remove(storage_id)
+
+    def add_relation(
+        self,
+        relation_name: str,
+        remote_app: str,
+        *,
+        app_data: Optional[Mapping[str, str]] = None,
+        unit_data: Optional[Mapping[str, str]] = None,
+    ) -> int:
+        """Declare that there is a new relation between this application and `remote_app`.
+
+        This function creates a relation with an application and triggers a
+        :class:`RelationCreatedEvent <ops.RelationCreatedEvent>`.
+        To match Juju's behaviour, it also creates a default network binding on this endpoint.
+        If you want to associate a custom network to this binding (or a global default network),
+        provide one using :meth:`add_network` before calling this function.
+
+        If `app_data` or `unit_data` are provided, also add a new unit
+        (``<remote_app>/0``) to the relation and trigger
+        :class:`RelationJoinedEvent <ops.RelationJoinedEvent>`. Then update
+        the application data if `app_data` is provided and the unit data if
+        `unit_data` is provided, triggering
+        :class:`RelationChangedEvent <ops.RelationChangedEvent>` after each update.
+        Alternatively, charm tests can call :meth:`add_relation_unit` and
+        :meth:`update_relation_data` explicitly.
+
+        For peer relations defined in the charm's metadata, :meth:`begin_with_initial_hooks`
+        will create them automatically, so the caller doesn't need to call :meth:`add_relation`.
+        If the caller chooses to add a peer relation by themselves, make sure to call
+        :meth:`add_relation` before :meth:`begin_with_initial_hooks` so that Harness won't
+        create it again.
+
+        Example usage::
+
+            secret_id = harness.add_model_secret('mysql', {'password': 'SECRET'})
+            harness.add_relation('db', 'mysql', unit_data={
+                'host': 'mysql.localhost,
+                'username': 'appuser',
+                'secret-id': secret_id,
+            })
+
+        Args:
+            relation_name: The relation on the charm that is being integrated with.
+            remote_app: The name of the application that is being integrated with.
+                To add a peer relation, set to the name of *this* application.
+            app_data: If provided, also add a new unit to the relation
+                (triggering relation-joined) and set the *application* relation data
+                (triggering relation-changed).
+            unit_data: If provided, also add a new unit to the relation
+                (triggering relation-joined) and set the *unit* relation data
+                (triggering relation-changed).
+
+        Return:
+            The ID of the relation created.
+        """
+        if not (
+            relation_name in self._meta.provides
+            or relation_name in self._meta.requires
+            or relation_name in self._meta.peers
+        ):
+            raise RelationNotFoundError(f'relation {relation_name!r} not declared in metadata')
+
+        relation_id = self._next_relation_id()
+        self._backend._relation_ids_map.setdefault(relation_name, []).append(relation_id)
+        self._backend._relation_names[relation_id] = relation_name
+        self._backend._relation_list_map[relation_id] = []
+        self._backend._relation_data_raw[relation_id] = {
+            remote_app: {},
+            self._backend.unit_name: {},
+            self._backend.app_name: {},
+        }
+
+        self._backend._relation_app_and_units[relation_id] = {
+            'app': remote_app,
+            'units': [],
+        }
+        # Reload the relation_ids list
+        if self._model is not None:
+            self._model.relations._invalidate(relation_name)
+        self._emit_relation_created(relation_name, relation_id, remote_app)
+
+        if app_data is not None or unit_data is not None:
+            remote_unit = remote_app + '/0'
+            self.add_relation_unit(relation_id, remote_unit)
+            if app_data is not None:
+                self.update_relation_data(relation_id, remote_app, app_data)
+            if unit_data is not None:
+                self.update_relation_data(relation_id, remote_unit, unit_data)
+
+        # If we have a default network binding configured, respect it.
+        if not self._backend._networks.get((None, None)):
+            # If we don't already have a network binding for this relation id, create one.
+            if not self._backend._networks.get((relation_name, relation_id)):
+                self.add_network('10.0.0.10', endpoint=relation_name, relation_id=relation_id)
+            # If we don't already have a default network binding for this endpoint, create one.
+            if not self._backend._networks.get((relation_name, None)):
+                self.add_network('192.0.2.0', endpoint=relation_name)
+
+        return relation_id
+
+    def remove_relation(self, relation_id: int) -> None:
+        """Remove a relation.
+
+        Args:
+            relation_id: The relation ID for the relation to be removed.
+
+        Raises:
+            RelationNotFoundError: if relation id is not valid
+        """
+        rel_names = self._backend._relation_names
+        try:
+            relation_name = rel_names[relation_id]
+            remote_app = self._backend.relation_remote_app_name(relation_id)
+        except KeyError as e:
+            raise model.RelationNotFoundError from e
+
+        rel_list_map = self._backend._relation_list_map
+        for unit_name in rel_list_map[relation_id].copy():
+            self.remove_relation_unit(relation_id, unit_name)
+
+        prev_broken_id = None  # Silence linter warning.
+        if self._model is not None:
+            # Let the model's RelationMapping know that this relation is broken.
+            # Normally, this is handled in `main`, but while testing we create
+            # the `Model` object and keep it around for multiple events.
+            prev_broken_id = self._model._relations._broken_relation_id
+            self._model.relations._broken_relation_id = relation_id
+            # Ensure that we don't offer a cached relation.
+            self._model.relations._invalidate(relation_name)
+        self._emit_relation_broken(relation_name, relation_id, remote_app)
+        if self._model is not None:
+            self._model.relations._broken_relation_id = prev_broken_id
+            self._model.relations._invalidate(relation_name)
+
+        self._backend._relation_app_and_units.pop(relation_id)
+        self._backend._relation_data_raw.pop(relation_id)
+        rel_list_map.pop(relation_id)
+        ids_map = self._backend._relation_ids_map
+        ids_map[relation_name].remove(relation_id)
+        rel_names.pop(relation_id)
+
+        # Remove secret grants that give access via this relation
+        for secret in self._backend._secrets:
+            secret.grants = {
+                rid: names for rid, names in secret.grants.items() if rid != relation_id
+            }
+
+    def _emit_relation_created(
+        self, relation_name: str, relation_id: int, remote_app: str
+    ) -> None:
+        """Trigger relation-created for a given relation with a given remote application."""
+        if self._charm is None or not self._hooks_enabled:
+            return
+        relation = self._model.get_relation(relation_name, relation_id)
+        app = self._model.get_app(remote_app)
+        self._charm.on[relation_name].relation_created.emit(relation, app)
+
+    def _emit_relation_broken(self, relation_name: str, relation_id: int, remote_app: str) -> None:
+        """Trigger relation-broken for a given relation with a given remote application."""
+        if self._charm is None or not self._hooks_enabled:
+            return
+        relation = self._model.get_relation(relation_name, relation_id)
+        app = self._model.get_app(remote_app)
+        self._charm.on[relation_name].relation_broken.emit(relation, app)
+
+    def add_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
+        """Add a new unit to a relation.
+
+        This will trigger a `relation_joined` event. This would naturally be
+        followed by a `relation_changed` event, which can be triggered with
+        :meth:`.update_relation_data`. This separation is artificial in the
+        sense that Juju will always fire the two, but is intended to make
+        testing relations and their data bags slightly more natural.
+
+        Unless finer-grained control is needed, most charm tests can call
+        :meth:`add_relation` with the `app_data` or `unit_data` argument
+        instead of using this function.
+
+        Example::
+
+            rel_id = harness.add_relation('db', 'postgresql')
+            harness.add_relation_unit(rel_id, 'postgresql/0')
+
+        Args:
+            relation_id: The integer relation identifier (as returned by :meth:`add_relation`).
+            remote_unit_name: A string representing the remote unit that is being added.
+        """
+        self._backend._relation_list_map[relation_id].append(remote_unit_name)
+        # we can write remote unit data iff we are not in a hook env
+        relation_name = self._backend._relation_names[relation_id]
+        relation = self._model.get_relation(relation_name, relation_id)
+
+        if not relation:
+            raise RuntimeError(
+                'Relation id {} is mapped to relation name {},'
+                'but no relation matching that name was found.'
+            )
+
+        self._backend._relation_data_raw[relation_id][remote_unit_name] = {}
+        app = relation.app
+        if not remote_unit_name.startswith(app.name):
+            warnings.warn(
+                f'Remote unit name invalid: '
+                f'the remote application of {relation_name} is called {app.name!r}; '
+                f'the remote unit name should be {app.name}/<some-number>, '
+                f'not {remote_unit_name!r}.'
+            )
+        app_and_units = self._backend._relation_app_and_units
+        app_and_units[relation_id]['units'].append(remote_unit_name)
+        # Make sure that the Model reloads the relation_list for this relation_id, as well as
+        # reloading the relation data for this unit.
+        remote_unit = self._model.get_unit(remote_unit_name)
+        unit_cache = relation.data.get(remote_unit, None)
+        if unit_cache is not None:
+            unit_cache._invalidate()
+        self._model.relations._invalidate(relation_name)
+        if self._charm is None or not self._hooks_enabled:
+            return
+        self._charm.on[relation_name].relation_joined.emit(relation, remote_unit.app, remote_unit)
+
+    def remove_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
+        """Remove a unit from a relation.
+
+        Example::
+
+            rel_id = harness.add_relation('db', 'postgresql')
+            harness.add_relation_unit(rel_id, 'postgresql/0')
+            ...
+            harness.remove_relation_unit(rel_id, 'postgresql/0')
+
+        This will trigger a `relation_departed` event. This would
+        normally be followed by a `relation_changed` event triggered
+        by Juju. However, when using the test harness, a
+        `relation_changed` event must be triggered using
+        :meth:`.update_relation_data`. This deviation from normal Juju
+        behaviour facilitates testing by making each step in the
+        charm life cycle explicit.
+
+        Args:
+            relation_id: The integer relation identifier (as returned by :meth:`add_relation`).
+            remote_unit_name: A string representing the remote unit that is being removed.
+        """
+        relation_name = self._backend._relation_names[relation_id]
+
+        # gather data to invalidate cache later
+        remote_unit = self._model.get_unit(remote_unit_name)
+        relation = self._model.get_relation(relation_name, relation_id)
+
+        if not relation:
+            # This should not really happen, since there being a relation name mapped
+            # to this ID in _relation_names should guarantee that you created the relation
+            # following the proper path, but still...
+            raise RuntimeError(
+                'Relation id {} is mapped to relation name {},'
+                'but no relation matching that name was found.'
+            )
+
+        unit_cache = relation.data.get(remote_unit, None)
+
+        # remove the unit from the list of units in the relation
+        relation.units.remove(remote_unit)
+
+        self._emit_relation_departed(relation_id, remote_unit_name)
+        # remove the relation data for the departed unit now that the event has happened
+        self._backend._relation_list_map[relation_id].remove(remote_unit_name)
+        self._backend._relation_app_and_units[relation_id]['units'].remove(remote_unit_name)
+        self._backend._relation_data_raw[relation_id].pop(remote_unit_name)
+        self.model._relations._invalidate(relation_name=relation.name)
+
+        if unit_cache is not None:
+            unit_cache._invalidate()
+
+    def _emit_relation_departed(self, relation_id: int, unit_name: str):
+        """Trigger relation-departed event for a given relation id and unit."""
+        if self._charm is None or not self._hooks_enabled:
+            return
+        rel_name = self._backend._relation_names[relation_id]
+        relation = self.model.get_relation(rel_name, relation_id)
+        if '/' in unit_name:
+            app_name = unit_name.split('/')[0]
+            app = self.model.get_app(app_name)
+            unit = self.model.get_unit(unit_name)
+        else:
+            raise ValueError('Invalid Unit Name')
+        self._charm.on[rel_name].relation_departed.emit(relation, app, unit, unit_name)
+
+    def get_relation_data(self, relation_id: int, app_or_unit: AppUnitOrName) -> Mapping[str, str]:
+        """Get the relation data bucket for a single app or unit in a given relation.
+
+        This ignores all of the safety checks of who can and can't see data in relations (eg,
+        non-leaders can't read their own application's relation data because there are no events
+        that keep that data up-to-date for the unit).
+
+        Args:
+            relation_id: The relation whose content we want to look at.
+            app_or_unit: An :class:`Application <ops.Application>` or
+                :class:`Unit <ops.Unit>` instance, or its name, whose data we
+                want to read.
+
+        Return:
+            A dict containing the relation data for ``app_or_unit`` or None.
+
+        Raises:
+            KeyError: if ``relation_id`` doesn't exist
+        """
+        name = _get_app_or_unit_name(app_or_unit)
+
+        # bypass access control by going directly to raw
+        return self._backend._relation_data_raw[relation_id].get(name, None)
+
+    def get_pod_spec(self) -> Tuple[Mapping[Any, Any], Mapping[Any, Any]]:
+        """Return the content of the pod spec as last set by the charm.
+
+        This returns both the pod spec and any k8s_resources that were supplied.
+        See the signature of :meth:`Pod.set_spec <ops.Pod.set_spec>`.
+        """
+        return self._backend._pod_spec
+
+    def get_container_pebble_plan(self, container_name: str) -> pebble.Plan:
+        """Return the current plan that Pebble is executing for the given container.
+
+        Args:
+            container_name: The simple name of the associated container
+
+        Return:
+            The Pebble plan for this container. Use
+            :meth:`Plan.to_yaml <ops.pebble.Plan.to_yaml>` to get a string
+            form for the content.
+
+        Raises:
+            KeyError: if no Pebble client exists for that container name (should only happen if
+                container is not present in ``metadata.yaml``).
+        """
+        client = self._backend._pebble_clients.get(container_name)
+        if client is None:
+            raise KeyError(f'no known pebble client for container "{container_name}"')
+        return client.get_plan()
+
+    def container_pebble_ready(self, container_name: str):
+        """Fire the pebble_ready hook for the associated container.
+
+        This will switch the given container's ``can_connect`` state to True
+        before the hook function is called.
+
+        It will do nothing if :meth:`begin()` has not been called.
+        """
+        if self._charm is None:
+            return
+        container = self.model.unit.get_container(container_name)
+        self.set_can_connect(container, True)
+        self.charm.on[container_name].pebble_ready.emit(container)
+
+    def pebble_notify(
+        self,
+        container_name: str,
+        key: str,
+        *,
+        data: Optional[Dict[str, str]] = None,
+        repeat_after: Optional[datetime.timedelta] = None,
+        type: pebble.NoticeType = pebble.NoticeType.CUSTOM,
+    ) -> str:
+        """Record a Pebble notice with the specified key and data.
+
+        If :meth:`begin` has been called and the notice is new or was repeated,
+        this will trigger a notice event of the appropriate type, for example
+        :class:`ops.PebbleCustomNoticeEvent`.
+
+        Args:
+            container_name: Name of workload container.
+            key: Notice key; must be in "example.com/path" format.
+            data: Data fields for this notice.
+            repeat_after: Only allow this notice to repeat after this duration
+                has elapsed (the default is to always repeat).
+            type: Notice type (currently only "custom" notices are supported).
+
+        Returns:
+            The notice's ID.
+        """
+        container = self.model.unit.get_container(container_name)
+        client = self._backend._pebble_clients[container.name]
+
+        id, new_or_repeated = client._notify(type, key, data=data, repeat_after=repeat_after)
+
+        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 and data:
+                kind = pebble.ChangeKind(data.get('kind'))
+                status = pebble.ChangeStatus(client.get_change(key).status)
+                if kind == pebble.ChangeKind.PERFORM_CHECK and status == pebble.ChangeStatus.ERROR:
+                    self.charm.on[container_name].pebble_check_failed.emit(
+                        container, data['check-name']
+                    )
+                elif (
+                    kind == pebble.ChangeKind.RECOVER_CHECK and status == pebble.ChangeStatus.DONE
+                ):
+                    self.charm.on[container_name].pebble_check_recovered.emit(
+                        container, data['check-name']
+                    )
+
+        return id
+
+    def get_workload_version(self) -> str:
+        """Read the workload version that was set by the unit."""
+        return self._backend._workload_version
+
+    def set_model_info(self, name: Optional[str] = None, uuid: Optional[str] = None) -> None:
+        """Set the name and UUID of the model that this is representing.
+
+        Cannot be called once :meth:`begin` has been called. Use it to set the
+        value that will be returned by :attr:`Model.name <ops.Model.name>` and
+        :attr:`Model.uuid <ops.Model.uuid>`.
+
+        This is a convenience method to invoke both :meth:`set_model_name`
+        and :meth:`set_model_uuid` at once.
+        """
+        if name is not None:
+            self.set_model_name(name)
+        if uuid is not None:
+            self.set_model_uuid(uuid)
+
+    def set_model_name(self, name: str) -> None:
+        """Set the name of the Model that this is representing.
+
+        Cannot be called once :meth:`begin` has been called. Use it to set the
+        value that will be returned by :attr:`Model.name <ops.Model.name>`.
+        """
+        if self._charm is not None:
+            raise RuntimeError('cannot set the Model name after begin()')
+        self._backend.model_name = name
+
+    def set_model_uuid(self, uuid: str) -> None:
+        """Set the uuid of the Model that this is representing.
+
+        Cannot be called once :meth:`begin` has been called. Use it to set the
+        value that will be returned by :attr:`Model.uuid <ops.Model.uuid>`.
+        """
+        if self._charm is not None:
+            raise RuntimeError('cannot set the Model uuid after begin()')
+        self._backend.model_uuid = uuid
+
+    def update_relation_data(
+        self,
+        relation_id: int,
+        app_or_unit: str,
+        key_values: Mapping[str, str],
+    ) -> None:
+        """Update the relation data for a given unit or application in a given relation.
+
+        This also triggers the `relation_changed` event for the given ``relation_id``.
+
+        Unless finer-grained control is needed, most charm tests can call
+        :meth:`add_relation` with the `app_data` or `unit_data` argument
+        instead of using this function.
+
+        Args:
+            relation_id: The integer relation ID representing this relation.
+            app_or_unit: The unit or application name that is being updated.
+                This can be the local or remote application.
+            key_values: Each key/value will be updated in the relation data.
+        """
+        relation_name = self._backend._relation_names[relation_id]
+        relation = self._model.get_relation(relation_name, relation_id)
+        if '/' in app_or_unit:
+            entity = self._model.get_unit(app_or_unit)
+        else:
+            entity = self._model.get_app(app_or_unit)
+
+        if not relation:
+            raise RuntimeError(
+                'Relation id {} is mapped to relation name {},'
+                'but no relation matching that name was found.'
+            )
+
+        rel_data = relation.data.get(entity, None)
+        if rel_data is not None:
+            # rel_data may have cached now-stale data, so _invalidate() it.
+            # Note, this won't cause the data to be loaded if it wasn't already.
+            rel_data._invalidate()
+
+        old_values = self._backend._relation_data_raw[relation_id][app_or_unit].copy()
+        assert isinstance(old_values, dict), old_values
+
+        # get a new relation instance to ensure a clean state
+        new_relation_instance = self.model.relations._get_unique(relation.name, relation_id)
+        assert new_relation_instance is not None  # type guard; this passed before...
+        databag = new_relation_instance.data[entity]
+        # ensure that WE as harness can temporarily write the databag
+        with self._event_context(''):
+            values_have_changed = False
+            for k, v in key_values.items():
+                if v == '':
+                    if databag.pop(k, None) != v:
+                        values_have_changed = True
+                else:
+                    if k not in databag or databag[k] != v:
+                        databag[k] = v  # this triggers relation-set
+                        values_have_changed = True
+
+        if not values_have_changed:
+            # Do not issue a relation changed event if the data bags have not changed
+            return
+
+        if app_or_unit == self._model.unit.name:
+            # No events for our own unit
+            return
+        if app_or_unit == self._model.app.name:
+            # updating our own app only generates an event if it is a peer relation and we
+            # aren't the leader
+            is_peer = self._meta.relations[relation_name].role.is_peer()
+            if not is_peer:
+                return
+            if self._model.unit.is_leader():
+                return
+        self._emit_relation_changed(relation_id, app_or_unit)
+
+    def _emit_relation_changed(self, relation_id: int, app_or_unit: str):
+        if self._charm is None or not self._hooks_enabled:
+            return
+        rel_name = self._backend._relation_names[relation_id]
+        relation = self.model.get_relation(rel_name, relation_id)
+        if '/' in app_or_unit:
+            app_name = app_or_unit.split('/')[0]
+            unit_name = app_or_unit
+            app = self.model.get_app(app_name)
+            unit = self.model.get_unit(unit_name)
+            args = (relation, app, unit)
+        else:
+            app_name = app_or_unit
+            app = self.model.get_app(app_name)
+            args = (relation, app)
+        self._charm.on[rel_name].relation_changed.emit(*args)
+
+    def _update_config(
+        self,
+        key_values: Optional[Mapping[str, Union[str, int, float, bool]]] = None,
+        unset: Iterable[str] = (),
+    ) -> None:
+        """Update the config as seen by the charm.
+
+        This will *not* trigger a `config_changed` event, and is intended for internal use.
+
+        Note that the `key_values` mapping will only add or update configuration items.
+        To remove existing ones, see the `unset` parameter.
+
+        Args:
+            key_values: A Mapping of key:value pairs to update in config.
+            unset: An iterable of keys to remove from config.
+        """
+        # NOTE: jam 2020-03-01 Note that this sort of works "by accident". Config
+        # is a LazyMapping, but its _load returns a dict and this method mutates
+        # the dict that Config is caching. Arguably we should be doing some sort
+        # of charm.framework.model.config._invalidate()
+        config = self._backend._config
+        if key_values is not None:
+            for key, value in key_values.items():
+                if key in config._defaults:
+                    if value is not None:
+                        config._config_set(key, value)
+                else:
+                    raise ValueError(f"unknown config option: '{key}'")
+
+        for key in unset:
+            # When the key is unset, revert to the default if one exists
+            default = config._defaults.get(key, None)
+            if default is not None:
+                config._config_set(key, default)
+            else:
+                config.pop(key, None)
+
+    def update_config(
+        self,
+        key_values: Optional[Mapping[str, Union[str, int, float, bool]]] = None,
+        unset: Iterable[str] = (),
+    ) -> None:
+        """Update the config as seen by the charm.
+
+        This will trigger a `config_changed` event.
+
+        Note that the ``key_values`` mapping will only add or update configuration items.
+        To remove existing ones, see the ``unset`` parameter.
+
+        Args:
+            key_values: A Mapping of key:value pairs to update in config.
+            unset: An iterable of keys to remove from config.
+                This sets the value to the default if defined,
+                otherwise removes the key altogether.
+
+        Raises:
+            ValueError: if the key is not present in the config.
+        """
+        self._update_config(key_values, unset)
+        if self._charm is None or not self._hooks_enabled:
+            return
+        self._charm.on.config_changed.emit()
+
+    def set_leader(self, is_leader: bool = True) -> None:
+        """Set whether this unit is the leader or not.
+
+        If this charm becomes a leader then `leader_elected` will be triggered.  If :meth:`begin`
+        has already been called, then the charm's peer relation should usually be added *prior* to
+        calling this method (with :meth:`add_relation`) to properly initialise and make
+        available relation data that leader elected hooks may want to access.
+
+        Args:
+            is_leader: Whether this unit is the leader.
+        """
+        self._backend._is_leader = is_leader
+
+        # Note: jam 2020-03-01 currently is_leader is cached at the ModelBackend level, not in
+        # the Model objects, so this automatically gets noticed.
+        if is_leader and self._charm is not None and self._hooks_enabled:
+            self._charm.on.leader_elected.emit()
+
+    def set_planned_units(self, num_units: int) -> None:
+        """Set the number of "planned" units.
+
+        This is the value that :meth:`Application.planned_units <ops.Application.planned_units>`
+        should return.
+
+        In real world circumstances, this number will be the number of units
+        in the application. That is, this number will be the number of peers
+        this unit has, plus one, as we count our own unit in the total.
+
+        A change to the return from ``planned_units`` will not generate an
+        event. Typically, a charm author would check planned units during a
+        config or install hook, or after receiving a peer relation joined
+        event.
+        """
+        if num_units < 0:
+            raise TypeError('num_units must be 0 or a positive integer.')
+        self._backend._planned_units = num_units
+
+    def reset_planned_units(self) -> None:
+        """Reset the planned units override.
+
+        This allows the harness to fall through to the built in methods that will try to
+        guess at a value for planned units, based on the number of peer relations that
+        have been setup in the testing harness.
+        """
+        self._backend._planned_units = None
+
+    def add_network(
+        self,
+        address: str,
+        *,
+        endpoint: Optional[str] = None,
+        relation_id: Optional[int] = None,
+        cidr: Optional[str] = None,
+        interface: str = 'eth0',
+        ingress_addresses: Optional[Iterable[str]] = None,
+        egress_subnets: Optional[Iterable[str]] = None,
+    ):
+        """Add simulated network data for the given relation endpoint (binding).
+
+        Calling this multiple times with the same (binding, relation_id)
+        combination will replace the associated network data.
+
+        Example::
+
+            # Set network info for default binding
+            harness.add_network('10.0.0.10')
+
+            # Or set network info for specific endpoint
+            harness.add_network('10.0.0.10', endpoint='db')
+
+        After either of those calls, the following will be true (in the first
+        case, the simulated network-get will fall back to the default binding)::
+
+            binding = harness.model.get_binding('db')
+            assert binding.network.bind_address == ipaddress.IPv4Address('10.0.0.10'))
+
+        Args:
+            address: Binding's IPv4 or IPv6 address.
+            endpoint: Name of relation endpoint (binding) to add network
+                data for. If not provided, add info for the default binding.
+            relation_id: Relation ID for the binding. If provided, the
+                endpoint argument must be provided and correspond. If not
+                provided, add network data for the endpoint's default binding.
+            cidr: Binding's CIDR. Defaults to "<address>/24" if address is an
+                IPv4 address, or "<address>/64" if address is IPv6 (the host
+                bits are cleared).
+            interface: Name of network interface.
+            ingress_addresses: List of ingress addresses. Defaults to [address].
+            egress_subnets: List of egress subnets. Defaults to [cidr].
+
+        Raises:
+            ModelError: If the endpoint is not a known relation name, or the
+                relation_id is incorrect or doesn't match the endpoint.
+            ValueError: If address is not an IPv4 or IPv6 address.
+        """
+        if endpoint is not None and endpoint not in self._meta.relations:
+            raise model.ModelError(f'{endpoint!r} is not a known endpoint')
+        if relation_id is not None:
+            if endpoint is None:
+                raise TypeError('endpoint must be set if relation_id is provided')
+            relation_name = self._backend._relation_names.get(relation_id)
+            if relation_name is None:
+                raise model.ModelError(
+                    f'relation_id {relation_id} has not been added; use add_relation'
+                )
+            if endpoint != relation_name:
+                raise model.ModelError(
+                    f'endpoint {endpoint!r} does not correspond to relation_id '
+                    f'{relation_id} ({relation_name!r})'
+                )
+
+        parsed_address = ipaddress.ip_address(address)  # raises ValueError if not an IP
+        if cidr is None:
+            if isinstance(parsed_address, ipaddress.IPv4Address):
+                cidr = str(ipaddress.IPv4Network(address + '/24', strict=False))
+            else:
+                cidr = str(ipaddress.IPv6Network(address + '/64', strict=False))
+        if ingress_addresses is None:
+            ingress_addresses = [address]
+        if egress_subnets is None:
+            egress_subnets = [cidr]
+
+        data = {
+            'bind-addresses': [
+                {
+                    'interface-name': interface,
+                    'addresses': [
+                        {'cidr': cidr, 'value': address},
+                    ],
+                }
+            ],
+            'egress-subnets': list(egress_subnets),
+            'ingress-addresses': list(ingress_addresses),
+        }
+        self._backend._networks[endpoint, relation_id] = data
+
+    def _get_backend_calls(self, reset: bool = True) -> List[Tuple[Any, ...]]:
+        """Return the calls that we have made to the TestingModelBackend.
+
+        This is useful mostly for testing the framework itself, so that we can assert that we
+        do/don't trigger extra calls.
+
+        Args:
+            reset: If True, reset the calls list back to empty, if false, the call list is
+                preserved.
+
+        Return:
+            ``[(call1, args...), (call2, args...)]``
+        """
+        calls = self._backend._calls.copy()
+        if reset:
+            self._backend._calls.clear()
+        return calls
+
+    def add_model_secret(self, owner: AppUnitOrName, content: Dict[str, str]) -> str:
+        """Add a secret owned by the remote application or unit specified.
+
+        This is named :code:`add_model_secret` instead of :code:`add_secret`
+        to avoid confusion with the :meth:`ops.Application.add_secret`
+        and :meth:`ops.Unit.add_secret` methods used by secret owner
+        charms.
+
+        Args:
+            owner: The name of the remote application (or specific remote
+                unit) that will own the secret.
+            content: A key-value mapping containing the payload of the secret,
+                for example :code:`{"password": "foo123"}`.
+
+        Return:
+            The ID of the newly-secret added.
+        """
+        owner_name = _get_app_or_unit_name(owner)
+        model.Secret._validate_content(content)
+        return self._backend._secret_add(content, owner_name)
+
+    def add_user_secret(self, content: Dict[str, str]) -> str:
+        """Add a secret owned by the user, simulating the ``juju add-secret`` command.
+
+        Args:
+            content: A key-value mapping containing the payload of the secret,
+                for example :code:`{"password": "foo123"}`.
+
+        Return:
+            The ID of the newly-added secret.
+
+        Example usage (the parameter ``harness`` in the test function is
+        a pytest fixture that does setup/teardown, see :class:`Harness`)::
+
+            # charmcraft.yaml
+            config:
+              options:
+                mysec:
+                  type: secret
+                  description: "tell me your secrets"
+
+            # charm.py
+            class MyVMCharm(ops.CharmBase):
+                def __init__(self, framework: ops.Framework):
+                    super().__init__(framework)
+                    framework.observe(self.on.config_changed, self._on_config_changed)
+
+                def _on_config_changed(self, event: ops.ConfigChangedEvent):
+                    mysec = self.config.get('mysec')
+                    if mysec:
+                        sec = self.model.get_secret(id=mysec, label="mysec")
+                        self.config_from_secret = sec.get_content()
+
+            # test_charm.py
+            def test_config_changed(harness):
+                secret_content = {'password': 'foo'}
+                secret_id = harness.add_user_secret(secret_content)
+                harness.grant_secret(secret_id, 'test-charm')
+                harness.begin()
+                harness.update_config({'mysec': secret_id})
+                secret = harness.model.get_secret(id=secret_id).get_content()
+                assert harness.charm.config_from_secret == secret.get_content()
+
+        """
+        model.Secret._validate_content(content)
+        # Although it's named a user-owned secret in Juju, technically, the owner is the
+        # Model, so the secret's owner is set to `Model.uuid`.
+        return self._backend._secret_add(content, self.model.uuid)
+
+    def _ensure_secret(self, secret_id: str) -> '_Secret':
+        secret = self._backend._get_secret(secret_id)
+        if secret is None:
+            raise RuntimeError(f'Secret {secret_id!r} not found')
+        return secret
+
+    def set_secret_content(self, secret_id: str, content: Dict[str, str]):
+        """Update a secret's content, add a new revision, and fire *secret-changed*.
+
+        Args:
+            secret_id: The ID of the secret to update. This should normally be
+                the return value of :meth:`add_model_secret`.
+            content: A key-value mapping containing the new payload.
+        """
+        model.Secret._validate_content(content)
+        secret = self._ensure_secret(secret_id)
+        if secret.owner_name in [self.model.app.name, self.model.unit.name]:
+            raise RuntimeError(
+                f'Secret {secret_id!r} owned by the charm under test, '
+                f"can't call set_secret_content"
+            )
+        new_revision = _SecretRevision(
+            revision=secret.revisions[-1].revision + 1,
+            content=content,
+        )
+        secret.revisions.append(new_revision)
+        self.charm.on.secret_changed.emit(secret_id, secret.label)
+
+    def grant_secret(self, secret_id: str, observer: AppUnitOrName):
+        """Grant read access to this secret for the given observer application or unit.
+
+        For user secrets, grant access to the application, simulating the
+        ``juju grant-secret`` command.
+
+        If the given application or unit has already been granted access to
+        this secret, do nothing.
+
+        Args:
+            secret_id: The ID of the secret to grant access to. This should
+                normally be the return value of :meth:`add_model_secret`.
+            observer: The name of the application (or specific unit) to grant
+                access to. A relation between this application and the charm
+                under test must already have been created.
+        """
+        secret = self._ensure_secret(secret_id)
+        app_or_unit_name = _get_app_or_unit_name(observer)
+
+        # User secrets:
+        if secret.owner_name == self.model.uuid:
+            secret.user_secrets_grants.add(app_or_unit_name)
+            return
+
+        # Model secrets:
+        if secret.owner_name in [self.model.app.name, self.model.unit.name]:
+            raise RuntimeError(
+                f"Secret {secret_id!r} owned by the charm under test, can't call grant_secret"
+            )
+        relation_id = self._secret_relation_id_to(secret)
+        if relation_id not in secret.grants:
+            secret.grants[relation_id] = set()
+        secret.grants[relation_id].add(app_or_unit_name)
+
+    def revoke_secret(self, secret_id: str, observer: AppUnitOrName):
+        """Revoke read access to this secret for the given observer application or unit.
+
+        If the given application or unit does not have access to this secret,
+        do nothing.
+
+        Args:
+            secret_id: The ID of the secret to revoke access for. This should
+                normally be the return value of :meth:`add_model_secret`.
+            observer: The name of the application (or specific unit) to revoke
+                access to. A relation between this application and the charm under
+                test must have already been created.
+        """
+        secret = self._ensure_secret(secret_id)
+        app_or_unit_name = _get_app_or_unit_name(observer)
+
+        # User secrets:
+        if secret.owner_name == self.model.uuid:
+            secret.user_secrets_grants.discard(app_or_unit_name)
+            return
+
+        # Model secrets:
+        if secret.owner_name in [self.model.app.name, self.model.unit.name]:
+            raise RuntimeError(
+                f'Secret {secret_id!r} owned by the charm under test, "'
+                f"can't call revoke_secret"
+            )
+
+        relation_id = self._secret_relation_id_to(secret)
+        if relation_id not in secret.grants:
+            return
+        secret.grants[relation_id].discard(app_or_unit_name)
+
+    def _secret_relation_id_to(self, secret: '_Secret') -> int:
+        """Get the relation ID of relation between this charm and the secret owner."""
+        owner_app = secret.owner_name.split('/')[0]
+        relation_id = self._backend._relation_id_to(owner_app)
+        if relation_id is None:
+            raise RuntimeError(
+                f'No relation between this charm ({self.model.app.name}) '
+                f'and secret owner ({owner_app})'
+            )
+        return relation_id
+
+    def get_secret_grants(self, secret_id: str, relation_id: int) -> Set[str]:
+        """Return the set of app and unit names granted to secret for this relation.
+
+        Args:
+            secret_id: The ID of the secret to get grants for.
+            relation_id: The ID of the relation granted access.
+        """
+        secret = self._ensure_secret(secret_id)
+        return secret.grants.get(relation_id, set())
+
+    def get_secret_revisions(self, secret_id: str) -> List[int]:
+        """Return the list of revision IDs for the given secret, oldest first.
+
+        Args:
+            secret_id: The ID of the secret to get revisions for.
+        """
+        secret = self._ensure_secret(secret_id)
+        return [r.revision for r in secret.revisions]
+
+    def trigger_secret_rotation(self, secret_id: str, *, label: Optional[str] = None):
+        """Trigger a secret-rotate event for the given secret.
+
+        This event is fired by Juju when a secret's rotation time elapses,
+        however, time-based events cannot be simulated appropriately in the
+        harness, so this fires it manually.
+
+        Args:
+            secret_id: The ID of the secret associated with the event.
+            label: Label value to send to the event. If None, the secret's
+                label is used.
+        """
+        secret = self._ensure_secret(secret_id)
+        if secret.owner_name == self.model.uuid:
+            raise RuntimeError('Cannot trigger the secret-rotate event for a user secret.')
+        if label is None:
+            label = secret.label
+        self.charm.on.secret_rotate.emit(secret_id, label)
+
+    def trigger_secret_removal(
+        self, secret_id: str, revision: int, *, label: Optional[str] = None
+    ):
+        """Trigger a secret-remove event for the given secret and revision.
+
+        This event is fired by Juju for a specific revision when all the
+        secret's observers have refreshed to a later revision, however, in the
+        harness call this method to fire the event manually.
+
+        Args:
+            secret_id: The ID of the secret associated with the event.
+            revision: Revision number to provide to the event. This should be
+                an item from the list returned by :meth:`get_secret_revisions`.
+            label: Label value to send to the event. If None, the secret's
+                label is used.
+        """
+        secret = self._ensure_secret(secret_id)
+        if label is None:
+            label = secret.label
+        self.charm.on.secret_remove.emit(secret_id, label, revision)
+
+    def trigger_secret_expiration(
+        self, secret_id: str, revision: int, *, label: Optional[str] = None
+    ):
+        """Trigger a secret-expired event for the given secret.
+
+        This event is fired by Juju when a secret's expiration time elapses,
+        however, time-based events cannot be simulated appropriately in the
+        harness, so this fires it manually.
+
+        Args:
+            secret_id: The ID of the secret associated with the event.
+            revision: Revision number to provide to the event. This should be
+                an item from the list returned by :meth:`get_secret_revisions`.
+            label: Label value to send to the event. If None, the secret's
+                label is used.
+        """
+        secret = self._ensure_secret(secret_id)
+        if secret.owner_name == self.model.uuid:
+            raise RuntimeError('Cannot trigger the secret-expired event for a user secret.')
+        if label is None:
+            label = secret.label
+        self.charm.on.secret_expired.emit(secret_id, label, revision)
+
+    def get_filesystem_root(self, container: Union[str, Container]) -> pathlib.Path:
+        """Return the temp directory path harness will use to simulate the container filesystem.
+
+        In a real container runtime, each container has an isolated root filesystem.
+        To simulate this behaviour, the testing harness manages a temporary directory for
+        each container. Any Pebble filesystem API calls will be translated
+        and mapped to this directory, as if the directory was the container's
+        filesystem root.
+
+        This process is quite similar to the ``chroot`` command. Charm tests should
+        treat the returned directory as the container's root directory (``/``).
+        The testing harness will not create any files or directories inside the
+        simulated container's root directory; it's up to the test to populate the container's
+        root directory with any files or directories the charm needs.
+
+        Regarding the file ownership: unprivileged users are unable to create files with distinct
+        ownership. To circumvent this limitation, the testing harness maps all user and group
+        options related to file operations to match the current user and group.
+
+        Example usage (the parameter ``harness`` in the test function is a pytest fixture
+        that does setup/teardown, see :class:`Harness`)::
+
+            # charm.py
+            class ExampleCharm(ops.CharmBase):
+                def __init__(self, *args):
+                    super().__init__(*args)
+                    self.framework.observe(self.on["mycontainer"].pebble_ready,
+                                           self._on_pebble_ready)
+
+                def _on_pebble_ready(self, event: ops.PebbleReadyEvent):
+                    self.hostname = event.workload.pull("/etc/hostname").read()
+
+            # test_charm.py
+            def test_hostname(harness):
+                root = harness.get_filesystem_root("mycontainer")
+                (root / "etc").mkdir()
+                (root / "etc" / "hostname").write_text("hostname.example.com")
+                harness.begin_with_initial_hooks()
+                assert harness.charm.hostname == "hostname.example.com"
+
+        Args:
+            container: The name of the container or the container instance.
+
+        Return:
+            The path of the temporary directory associated with the specified container.
+        """
+        # It's okay to access the container directly in this context, as its creation has already
+        # been ensured during the model's initialization.
+        container_name = container if isinstance(container, str) else container.name
+        return self._backend._pebble_clients[container_name]._root
+
+    def evaluate_status(self) -> None:
+        """Trigger the collect-status events and set application and/or unit status.
+
+        This will always trigger ``collect_unit_status``, and set the unit status if any
+        statuses were added.
+
+        If running on the leader unit (:meth:`set_leader` has been called with ``True``),
+        this will trigger ``collect_app_status``, and set the application status if any
+        statuses were added.
+
+        Tests should normally call this and then assert that ``self.model.app.status``
+        or ``self.model.unit.status`` is the value expected.
+
+        Evaluation is not "additive"; this method resets the added statuses before
+        triggering each collect-status event.
+        """
+        self.charm.app._collected_statuses = []
+        self.charm.unit._collected_statuses = []
+        charm._evaluate_status(self.charm)
+
+    def handle_exec(
+        self,
+        container: Union[str, Container],
+        command_prefix: Sequence[str],
+        *,
+        handler: Optional[ExecHandler] = None,
+        result: Optional[Union[int, str, bytes, ExecResult]] = None,
+    ):
+        r"""Register a handler to simulate the Pebble command execution.
+
+        This allows a test harness to simulate the behavior of running commands in a container.
+        When :meth:`ops.Container.exec` is triggered, the registered handler is used to
+        generate stdout and stderr for the simulated execution.
+
+        A ``handler`` or a ``result`` may be provided, but not both:
+
+        - A ``handler`` is a function accepting :class:`ops.testing.ExecArgs` and returning
+          :class:`ops.testing.ExecResult` as the simulated process outcome. For cases that
+          have side effects but don't return output, the handler can return ``None``, which
+          is equivalent to returning ``ExecResult()``.
+
+        - A ``result`` is for simulations that don't need to inspect the ``exec`` arguments; the
+          output or exit code is provided directly. Setting ``result`` to str or bytes means
+          use that string as stdout (with exit code 0); setting ``result`` to int means return
+          that exit code (and no stdout).
+
+        If ``handle_exec`` is called more than once with overlapping command prefixes, the
+        longest match takes precedence. The registration of an execution handler can be updated by
+        re-registering with the same command prefix.
+
+        The execution handler receives the timeout value in the ``ExecArgs``. If needed,
+        it can raise a ``TimeoutError`` to inform the harness that a timeout occurred.
+
+        If :meth:`ops.Container.exec` is called with ``combine_stderr=True``, the execution
+        handler should, if required, weave the simulated standard error into the standard output.
+        The harness checks the result and will raise an exception if stderr is non-empty.
+
+        Args:
+            container: The specified container or its name.
+            command_prefix: The command prefix to register against.
+            handler: A handler function that simulates the command's execution.
+            result: A simplified form to specify the command's simulated result.
+
+        Example usage::
+
+            # produce no output and return 0 for every command
+            harness.handle_exec('container', [], result=0)
+
+            # simple example that just produces output (exit code 0)
+            harness.handle_exec('webserver', ['ls', '/etc'], result='passwd\nprofile\n')
+
+            # slightly more complex (use stdin)
+            harness.handle_exec(
+                'c1', ['sha1sum'],
+                handler=lambda args: ExecResult(stdout=hashlib.sha1(args.stdin).hexdigest()))
+
+            # more complex example using args.command
+            def docker_handler(args: testing.ExecArgs) -> testing.ExecResult:
+                match args.command:
+                    case ['docker', 'run', image]:
+                        return testing.ExecResult(stdout=f'running {image}')
+                    case ['docker', 'ps']:
+                        return testing.ExecResult(stdout='CONTAINER ID   IMAGE ...')
+                    case _:
+                        return testing.ExecResult(exit_code=1, stderr='unknown command')
+
+            harness.handle_exec('database', ['docker'], handler=docker_handler)
+
+            # handle timeout
+            def handle_timeout(args: testing.ExecArgs) -> int:
+                if args.timeout is not None and args.timeout < 10:
+                    raise TimeoutError
+                return 0
+
+            harness.handle_exec('database', ['foo'], handler=handle_timeout)
+        """
+        if (handler is None and result is None) or (handler is not None and result is not None):
+            raise TypeError('Either handler or result must be provided, but not both.')
+        container_name = container if isinstance(container, str) else container.name
+        if result is not None:
+            if isinstance(result, int) and not isinstance(result, bool):
+                result = ExecResult(exit_code=result)
+            elif isinstance(result, (str, bytes)):
+                result = ExecResult(stdout=result)
+            elif not isinstance(result, ExecResult):
+                raise TypeError(
+                    f'result must be int, str, bytes, or ExecResult, '
+                    f'not {result.__class__.__name__}'
+                )
+        self._backend._pebble_clients[container_name]._handle_exec(
+            command_prefix=command_prefix,
+            handler=(lambda _: result) if handler is None else handler,  # type: ignore
+        )
+
+    @property
+    def reboot_count(self) -> int:
+        """Number of times the charm has called :meth:`ops.Unit.reboot`."""
+        return self._backend._reboot_count
+
+    def run_action(
+        self, action_name: str, params: Optional[Dict[str, Any]] = None
+    ) -> ActionOutput:
+        """Simulates running a charm action, as with ``juju run``.
+
+        Use this only after calling :meth:`begin`.
+
+        Validates that no required parameters are missing, and that additional
+        parameters are not provided if that is not permitted. Does not validate
+        the types of the parameters - you can use the
+        `jsonschema <https://github.com/python-jsonschema/jsonschema>`_ package to
+        do this in your tests; for example::
+
+            schema = harness.charm.meta.actions["action-name"].parameters
+            try:
+                jsonschema.validate(instance=params, schema=schema)
+            except jsonschema.ValidationError:
+                # Do something about the invalid params.
+                ...
+            harness.run_action("action-name", params)
+
+        Args:
+            action_name: the name of the action to run, as found in ``actions.yaml``.
+            params: override the default parameter values found in ``actions.yaml``.
+                If a parameter is not in ``params``, or ``params`` is ``None``, then
+                the default value from ``actions.yaml`` will be used.
+
+        Raises:
+            ActionFailed: if :meth:`ops.ActionEvent.fail` is called. Note that this will
+              be raised at the end of the ``run_action`` call, not immediately when
+              :code:`fail()` is called, to match the run-time behaviour.
+        """
+        try:
+            action_meta = self.charm.meta.actions[action_name]
+        except KeyError:
+            raise RuntimeError(f'Charm does not have a {action_name!r} action.') from None
+        if params is None:
+            params = {}
+        for key in action_meta.required:
+            # Juju requires that the key is in the passed parameters, even if there is a default
+            # value in actions.yaml.
+            if key not in params:
+                raise RuntimeError(f'{key!r} parameter is required, but missing.')
+        if not action_meta.additional_properties:
+            for key in params:
+                if key not in action_meta.parameters:
+                    # Match Juju's error message.
+                    raise model.ModelError(
+                        f'additional property "{key}" is not allowed, '
+                        f'given {{"{key}":{params[key]!r}}}'
+                    )
+        action_under_test = _RunningAction(action_name, ActionOutput([], {}), params)
+        handler = getattr(self.charm.on, f"{action_name.replace('-', '_')}_action")
+        self._backend._running_action = action_under_test
+        self._action_id_counter += 1
+        handler.emit(str(self._action_id_counter))
+        self._backend._running_action = None
+        if action_under_test.failure_message is not None:
+            raise ActionFailed(
+                message=action_under_test.failure_message, output=action_under_test.output
+            )
+        return action_under_test.output
+
+    def set_cloud_spec(self, spec: 'model.CloudSpec'):
+        """Set cloud specification (metadata) including credentials.
+
+        Call this method before the charm calls :meth:`ops.Model.get_cloud_spec`.
+
+        Example usage (the parameter ``harness`` in the test function is
+        a pytest fixture that does setup/teardown, see :class:`Harness`)::
+
+            # charm.py
+            class MyVMCharm(ops.CharmBase):
+                def __init__(self, framework: ops.Framework):
+                    super().__init__(framework)
+                    framework.observe(self.on.start, self._on_start)
+
+                def _on_start(self, event: ops.StartEvent):
+                    self.cloud_spec = self.model.get_cloud_spec()
+
+            # test_charm.py
+            def test_start(harness):
+                cloud_spec = ops.model.CloudSpec.from_dict({
+                    'name': 'localhost',
+                    'type': 'lxd',
+                    'endpoint': 'https://127.0.0.1:8443',
+                    'credential': {
+                        'auth-type': 'certificate',
+                        'attrs': {
+                            'client-cert': 'foo',
+                            'client-key': 'bar',
+                            'server-cert': 'baz'
+                        },
+                    },
+                })
+                harness.set_cloud_spec(cloud_spec)
+                harness.begin()
+                harness.charm.on.start.emit()
+                assert harness.charm.cloud_spec == cloud_spec
+
+        """
+        self._backend._cloud_spec = spec
+
+
+def _get_app_or_unit_name(app_or_unit: AppUnitOrName) -> str:
+    """Return name of given application or unit (return strings directly)."""
+    if isinstance(app_or_unit, (model.Application, model.Unit)):
+        return app_or_unit.name
+    elif isinstance(app_or_unit, str):
+        return app_or_unit
+    else:
+        raise TypeError(f'Expected Application | Unit | str, got {type(app_or_unit)}')
+
+
+def _record_calls(cls: Any):
+    """Replace methods on cls with methods that record that they have been called.
+
+    Iterate all attributes of cls, and for public methods, replace them with a wrapped method
+    that records the method called along with the arguments and keyword arguments.
+    """
+    for meth_name, orig_method in cls.__dict__.items():
+        if meth_name.startswith('_'):
+            continue
+
+        def decorator(orig_method: Any):
+            def wrapped(self: '_TestingModelBackend', *args: Any, **kwargs: Any):
+                full_args = (orig_method.__name__, *args)
+                if kwargs:
+                    full_args = (*full_args, kwargs)
+                self._calls.append(full_args)
+                return orig_method(self, *args, **kwargs)
+
+            return wrapped
+
+        setattr(cls, meth_name, decorator(orig_method))
+    return cls
+
+
+def _copy_docstrings(source_cls: Any):
+    """Copy the docstrings from source_cls to target_cls.
+
+    Use this as:
+      @_copy_docstrings(source_class)
+      class TargetClass:
+
+    And for any public method that exists on both classes, it will copy the
+    __doc__ for that method.
+    """
+
+    def decorator(target_cls: Any):
+        for meth_name in target_cls.__dict__:
+            if meth_name.startswith('_'):
+                continue
+            source_method = source_cls.__dict__.get(meth_name)
+            if source_method is not None and source_method.__doc__:
+                target_cls.__dict__[meth_name].__doc__ = source_method.__doc__
+        return target_cls
+
+    return decorator
+
+
+@_record_calls
+class _TestingConfig(Dict[str, Union[str, int, float, bool]]):
+    """Represents the Juju Config."""
+
+    _supported_types = {
+        'string': str,
+        'boolean': bool,
+        'int': int,
+        'float': float,
+        'secret': str,  # There is some special structure, but they are strings.
+    }
+
+    def __init__(self, config: '_RawConfig'):
+        super().__init__()
+        self._spec = config
+        self._defaults = self._load_defaults(config)
+
+        for key, value in self._defaults.items():
+            if value is None:
+                continue
+            self._config_set(key, value)
+
+    @staticmethod
+    def _load_defaults(charm_config: '_RawConfig') -> Dict[str, Union[str, int, float, bool]]:
+        """Load default values from config.yaml.
+
+        Handle the case where a user doesn't supply explicit config snippets.
+        """
+        if not charm_config:
+            return {}
+        cfg: Dict[str, _ConfigOption] = charm_config.get('options', {})
+        return {key: value.get('default', None) for key, value in cfg.items()}
+
+    def _config_set(self, key: str, value: Union[str, int, float, bool]):
+        # this is only called by the harness itself
+        # we don't do real serialization/deserialization, but we do check that the value
+        # has the expected type.
+        option = self._spec.get('options', {}).get(key)
+        if not option:
+            raise RuntimeError(
+                f'Unknown config option {key}; '
+                'not declared in `config.yaml`.'
+                'Check https://juju.is/docs/sdk/config for the '
+                'spec.'
+            )
+
+        declared_type = option.get('type')
+        if not declared_type:
+            raise RuntimeError(
+                f'Incorrectly formatted `options.yaml`, option {key} '
+                'is expected to declare a `type`.'
+            )
+
+        if declared_type not in self._supported_types:
+            raise RuntimeError(
+                'Incorrectly formatted `options.yaml`: `type` needs to be one '
+                'of [{}], not {}.'.format(', '.join(self._supported_types), declared_type)
+            )
+
+        if type(value) is not self._supported_types[declared_type]:
+            raise RuntimeError(
+                f'Config option {key} is supposed to be of type '
+                f'{declared_type}, not `{type(value).__name__}`.'
+            )
+
+        # call 'normal' setattr.
+        dict.__setitem__(self, key, value)  # type: ignore
+
+    def __setitem__(self, key: Any, value: Any):
+        # if a charm attempts to config[foo] = bar:
+        raise TypeError("'ConfigData' object does not support item assignment")
+
+
+class _TestingRelationDataContents(Dict[str, str]):
+    def __setitem__(self, key: str, value: str):
+        if not isinstance(key, str):
+            raise model.RelationDataError(f'relation data keys must be strings, not {type(key)}')
+        if not isinstance(value, str):
+            raise model.RelationDataError(
+                f'relation data values must be strings, not {type(value)}'
+            )
+        super().__setitem__(key, value)
+
+    def copy(self):
+        return _TestingRelationDataContents(super().copy())
+
+
+@dataclasses.dataclass
+class _SecretRevision:
+    revision: int
+    content: Dict[str, str]
+
+
+@dataclasses.dataclass
+class _Secret:
+    id: str
+    owner_name: str
+    revisions: List[_SecretRevision]
+    rotate_policy: Optional[str]
+    expire_time: Optional[datetime.datetime]
+    label: Optional[str] = None
+    description: Optional[str] = None
+    tracked: int = 1
+    grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict)
+    user_secrets_grants: Set[str] = dataclasses.field(default_factory=set)
+
+
+@_copy_docstrings(model._ModelBackend)
+@_record_calls
+class _TestingModelBackend:
+    """This conforms to the interface for ModelBackend but provides canned data.
+
+    DO NOT use this class directly, it is used by `Harness`_ to drive the model.
+    `Harness`_ is responsible for maintaining the internal consistency of the values here,
+    as the only public methods of this type are for implementing ModelBackend.
+    """
+
+    def __init__(
+        self,
+        unit_name: str,
+        meta: charm.CharmMeta,
+        config: '_RawConfig',
+        juju_context: _JujuContext,
+    ):
+        self._juju_context = juju_context
+        self.unit_name = unit_name
+        self.app_name = self.unit_name.split('/')[0]
+        self.model_name = None
+        self.model_uuid = str(uuid.uuid4())
+
+        self._harness_tmp_dir = tempfile.TemporaryDirectory(prefix='ops-harness-')
+        self._harness_storage_path = pathlib.Path(self._harness_tmp_dir.name) / 'storages'
+        self._harness_container_path = pathlib.Path(self._harness_tmp_dir.name) / 'containers'
+        self._harness_storage_path.mkdir()
+        self._harness_container_path.mkdir()
+        # this is used by the _record_calls decorator
+        self._calls: List[Tuple[Any, ...]] = []
+        self._meta = meta
+        # relation name to [relation_ids,...]
+        self._relation_ids_map: Dict[str, List[int]] = {}
+        # reverse map from relation_id to relation_name
+        self._relation_names: Dict[int, str] = {}
+        # relation_id: [unit_name,...]
+        self._relation_list_map: Dict[int, List[str]] = {}
+        # {relation_id: {name: Dict[str: str]}}
+        self._relation_data_raw: Dict[int, Dict[str, Dict[str, str]]] = {}
+        # {relation_id: {"app": app_name, "units": ["app/0",...]}
+        self._relation_app_and_units: Dict[int, _RelationEntities] = {}
+        self._config = _TestingConfig(config)
+        self._is_leader: bool = False
+        # {resource_name: resource_content}
+        # where resource_content is (path, content)
+        self._resources_map: Dict[str, Tuple[str, Union[str, bytes]]] = {}
+        # fixme: understand how this is used and adjust the type
+        self._pod_spec: Optional[Tuple[model.K8sSpec, Any]] = None
+        self._app_status: _RawStatus = {'status': 'unknown', 'message': ''}
+        self._unit_status: _RawStatus = {'status': 'maintenance', 'message': ''}
+        self._workload_version: Optional[str] = None
+        self._resource_dir: Optional[tempfile.TemporaryDirectory[Any]] = None
+        # Format:
+        # { "storage_name": {"<ID1>": { <other-properties> }, ... }
+        # <ID1>: device id that is key for given storage_name
+        # Initialize the _storage_list with values present on metadata.yaml
+        self._storage_list: Dict[str, Dict[int, Dict[str, Any]]] = {
+            k: {} for k in self._meta.storages
+        }
+        self._storage_attached: Dict[str, Set[int]] = {k: set() for k in self._meta.storages}
+        self._storage_index_counter = 0
+        # {container_name : _TestingPebbleClient}
+        self._pebble_clients: Dict[str, _TestingPebbleClient] = {}
+        self._pebble_clients_can_connect: Dict[_TestingPebbleClient, bool] = {}
+        self._planned_units: Optional[int] = None
+        self._hook_is_running = ''
+        self._secrets: List[_Secret] = []
+        self._opened_ports: Set[model.Port] = set()
+        self._networks: Dict[Tuple[Optional[str], Optional[int]], _NetworkDict] = {}
+        self._reboot_count = 0
+        self._running_action: Optional[_RunningAction] = None
+        self._cloud_spec: Optional[model.CloudSpec] = None
+
+    def _can_connect(self, pebble_client: '_TestingPebbleClient') -> bool:
+        """Returns whether the mock client is active and can support API calls with no errors."""
+        return self._pebble_clients_can_connect[pebble_client]
+
+    def _set_can_connect(self, pebble_client: '_TestingPebbleClient', val: bool):
+        """Manually sets the can_connect state for the given mock client."""
+        if pebble_client not in self._pebble_clients_can_connect:
+            msg = 'cannot set can_connect for the client - are you running a "real" pebble test?'
+            raise RuntimeError(msg)
+        self._pebble_clients_can_connect[pebble_client] = val
+
+    def _cleanup(self):
+        if self._resource_dir is not None:
+            self._resource_dir.cleanup()
+            self._resource_dir = None
+        self._harness_tmp_dir.cleanup()
+
+    def _get_resource_dir(self) -> pathlib.Path:
+        if self._resource_dir is None:
+            # In actual Juju, the resource path for a charm's resource is
+            # $AGENT_DIR/resources/$RESOURCE_NAME/$RESOURCE_FILENAME
+            # However, charms shouldn't depend on this.
+            self._resource_dir = tempfile.TemporaryDirectory(prefix='tmp-ops-test-resource-')
+        res_dir_name = cast(str, self._resource_dir.name)
+        return pathlib.Path(res_dir_name)
+
+    def relation_ids(self, relation_name: str) -> List[int]:
+        try:
+            return self._relation_ids_map[relation_name]
+        except KeyError:
+            if relation_name not in self._meta.relations:
+                raise model.ModelError(f'{relation_name} is not a known relation') from None
+            no_ids: List[int] = []
+            return no_ids
+
+    def relation_list(self, relation_id: int):
+        try:
+            return self._relation_list_map[relation_id]
+        except KeyError:
+            raise model.RelationNotFoundError from None
+
+    def relation_remote_app_name(self, relation_id: int) -> Optional[str]:
+        if relation_id not in self._relation_app_and_units:
+            # Non-existent or dead relation
+            return None
+        return self._relation_app_and_units[relation_id]['app']
+
+    def relation_get(self, relation_id: int, member_name: str, is_app: bool):
+        if is_app and '/' in member_name:
+            member_name = member_name.split('/')[0]
+        if relation_id not in self._relation_data_raw:
+            raise model.RelationNotFoundError()
+        return self._relation_data_raw[relation_id][member_name]
+
+    def update_relation_data(
+        self, relation_id: int, _entity: Union[model.Unit, model.Application], key: str, value: str
+    ):
+        # this is where the 'real' backend would call relation-set.
+        raw_data = self._relation_data_raw[relation_id][_entity.name]
+        if value == '':
+            raw_data.pop(key, None)
+        else:
+            raw_data[key] = value
+
+    def relation_set(self, relation_id: int, key: str, value: str, is_app: bool):
+        if not isinstance(is_app, bool):
+            raise TypeError('is_app parameter to relation_set must be a boolean')
+
+        if 'relation_broken' in self._hook_is_running and not self.relation_remote_app_name(
+            relation_id
+        ):
+            raise RuntimeError(
+                'remote-side relation data cannot be accessed during a relation-broken event'
+            )
+
+        if relation_id not in self._relation_data_raw:
+            raise RelationNotFoundError(relation_id)
+
+        relation = self._relation_data_raw[relation_id]
+        bucket_key = self.app_name if is_app else self.unit_name
+        if bucket_key not in relation:
+            relation[bucket_key] = {}
+        bucket = relation[bucket_key]
+        if value == '':
+            bucket.pop(key, None)
+        else:
+            bucket[key] = value
+
+    def config_get(self) -> _TestingConfig:
+        return self._config
+
+    def is_leader(self):
+        return self._is_leader
+
+    def application_version_set(self, version: str):
+        self._workload_version = version
+
+    def resource_get(self, resource_name: str):
+        if resource_name not in self._resources_map:
+            raise model.ModelError(
+                'ERROR could not download resource: HTTP request failed: '
+                'Get https://.../units/unit-{}/resources/{}: resource#{}/{} not found'.format(
+                    self.unit_name.replace('/', '-'), resource_name, self.app_name, resource_name
+                )
+            )
+        filename, contents = self._resources_map[resource_name]
+        resource_dir = self._get_resource_dir()
+        resource_filename = resource_dir / resource_name / filename
+        if not resource_filename.exists():
+            mode = 'wb' if isinstance(contents, bytes) else 'wt'
+            resource_filename.parent.mkdir(exist_ok=True)
+            with resource_filename.open(mode=mode) as resource_file:
+                resource_file.write(contents)
+        return resource_filename
+
+    def pod_spec_set(self, spec: 'model.K8sSpec', k8s_resources: Any):  # fixme: any
+        self._pod_spec = (spec, k8s_resources)
+
+    def status_get(self, *, is_app: bool = False):
+        if is_app:
+            return self._app_status
+        else:
+            return self._unit_status
+
+    def status_set(self, status: 'StatusName', message: str = '', *, is_app: bool = False):
+        if status in [model.ErrorStatus.name, model.UnknownStatus.name]:
+            raise model.ModelError(
+                f'ERROR invalid status "{status}", expected one of'
+                ' [maintenance blocked waiting active]'
+            )
+        if is_app:
+            self._app_status = {'status': status, 'message': message}
+        else:
+            self._unit_status = {'status': status, 'message': message}
+
+    def storage_list(self, name: str, include_detached: bool = False):
+        """Returns a list of all attached storage mounts for the given storage name.
+
+        Args:
+            name: name (i.e. from metadata.yaml).
+            include_detached: True to include unattached storage mounts as well.
+        """
+        return [
+            index
+            for index in self._storage_list[name]
+            if include_detached or self._storage_is_attached(name, index)
+        ]
+
+    def storage_get(self, storage_name_id: str, attribute: str) -> Any:
+        name, index = storage_name_id.split('/', 1)
+        index = int(index)
+        try:
+            if index not in self._storage_attached[name]:
+                raise KeyError()  # Pretend the key isn't there
+            else:
+                return self._storage_list[name][index][attribute]
+        except KeyError:
+            raise model.ModelError(
+                f'ERROR invalid value "{name}/{index}" for option -s: storage not found'
+            ) from None
+
+    def storage_add(self, name: str, count: int = 1) -> List[int]:
+        if '/' in name:
+            raise model.ModelError('storage name cannot contain "/"')
+
+        if name not in self._storage_list:
+            self._storage_list[name] = {}
+        result: List[int] = []
+        for _ in range(count):
+            index = self._storage_index_counter
+            self._storage_index_counter += 1
+            self._storage_list[name][index] = {
+                'location': os.path.join(self._harness_storage_path, name, str(index)),
+            }
+            result.append(index)
+        return result
+
+    def _storage_detach(self, storage_id: str):
+        # NOTE: This is an extra function for _TestingModelBackend to simulate
+        # detachment of a storage unit.  This is not present in ops.model._ModelBackend.
+        name, index = storage_id.split('/', 1)
+        index = int(index)
+
+        for container, client in self._pebble_clients.items():
+            for mount in self._meta.containers[container].mounts.values():
+                if mount.storage != name:
+                    continue
+                root = client._root
+                (root / mount.location[1:]).unlink()
+
+        if self._storage_is_attached(name, index):
+            self._storage_attached[name].remove(index)
+
+    def _storage_attach(self, storage_id: str):
+        """Mark the named storage_id as attached and return True if it was previously detached."""
+        # NOTE: This is an extra function for _TestingModelBackend to simulate
+        # re-attachment of a storage unit.  This is not present in
+        # ops.model._ModelBackend.
+        name, index = storage_id.split('/', 1)
+
+        for container, client in self._pebble_clients.items():
+            for mount in self._meta.containers[container].mounts.values():
+                if mount.storage != name:
+                    continue
+                for store in self._storage_list[mount.storage].values():
+                    root = client._root
+                    mounting_dir = root / mount.location[1:]
+                    mounting_dir.parent.mkdir(parents=True, exist_ok=True)
+                    target_dir = pathlib.Path(store['location'])
+                    target_dir.mkdir(parents=True, exist_ok=True)
+                    try:
+                        mounting_dir.symlink_to(target_dir, target_is_directory=True)
+                    except FileExistsError:
+                        # If the symlink is already the one we want, then we
+                        # don't need to do anything here.
+                        # NOTE: In Python 3.9, this can use `mounting_dir.readlink()`
+                        if not mounting_dir.is_symlink() or os.readlink(mounting_dir) != str(
+                            target_dir
+                        ):
+                            raise
+
+        index = int(index)
+        if not self._storage_is_attached(name, index):
+            self._storage_attached[name].add(index)
+            return True
+        return False
+
+    def _storage_is_attached(self, storage_name: str, storage_index: int):
+        return storage_index in self._storage_attached[storage_name]
+
+    def _storage_remove(self, storage_id: str):
+        # NOTE: This is an extra function for _TestingModelBackend to simulate
+        # full removal of a storage unit.  This is not present in
+        # ops.model._ModelBackend.
+        self._storage_detach(storage_id)
+        name, index = storage_id.split('/', 1)
+        index = int(index)
+        self._storage_list[name].pop(index, None)
+
+    def action_get(self) -> Dict[str, Any]:
+        params: Dict[str, Any] = {}
+        assert self._running_action is not None
+        action_meta = self._meta.actions[self._running_action.name]
+        for name, meta in action_meta.parameters.items():
+            if 'default' in meta:
+                params[name] = meta['default']
+        params.update(self._running_action.parameters)
+        return params
+
+    def action_set(self, results: Dict[str, Any]):
+        assert self._running_action is not None
+        for key in ('stdout', 'stderr', 'stdout-encoding', 'stderr-encoding'):
+            if key in results:
+                # Match Juju's error message.
+                raise model.ModelError(f'ERROR cannot set reserved action key "{key}"')
+        # Although it's not necessary, we go through the same flattening process
+        # as the real backend, in order to give Charmers advance notice if they
+        # are setting results that will not work.
+        # This also does some validation on keys to make sure that they fit the
+        # Juju constraints.
+        model._format_action_result_dict(results)  # Validate, but ignore returned value.
+        self._running_action.output.results.update(results)
+
+    def action_log(self, message: str):
+        assert self._running_action is not None
+        self._running_action.output.logs.append(message)
+
+    def action_fail(self, message: str = ''):
+        assert self._running_action is not None
+        # If fail is called multiple times, Juju only retains the most recent failure message.
+        self._running_action.failure_message = message
+
+    def network_get(self, endpoint_name: str, relation_id: Optional[int] = None) -> '_NetworkDict':
+        data = self._networks.get((endpoint_name, relation_id))
+        if data is not None:
+            return data
+        if relation_id is not None:
+            # Fall back to the default binding for this endpoint
+            data = self._networks.get((endpoint_name, None))
+            if data is not None:
+                return data
+        # No custom data per relation ID or binding, return the default binding
+        data = self._networks.get((None, None))
+        if data is not None:
+            return data
+        raise RelationNotFoundError
+
+    def add_metrics(self, metrics, labels=None):  # type:ignore
+        raise NotImplementedError(self.add_metrics)  # type:ignore
+
+    @classmethod
+    def log_split(cls, message, max_len=model.MAX_LOG_LINE_LEN):  # type:ignore
+        raise NotImplementedError(cls.log_split)  # type:ignore
+
+    def juju_log(self, level, msg):  # type:ignore
+        raise NotImplementedError(self.juju_log)  # type:ignore
+
+    def get_pebble(self, socket_path: str) -> '_TestingPebbleClient':
+        container = socket_path.split('/')[3]  # /charm/containers/<container_name>/pebble.socket
+        client = self._pebble_clients.get(container, None)
+        if client is None:
+            container_root = self._harness_container_path / container
+            container_root.mkdir()
+            client = _TestingPebbleClient(self, container_root=container_root)
+
+            # we need to know which container a new pebble client belongs to
+            # so we can figure out which storage mounts must be simulated on
+            # this pebble client's mock file systems when storage is
+            # attached/detached later.
+            self._pebble_clients[container] = client
+
+        self._pebble_clients_can_connect[client] = False
+        return client
+
+    def planned_units(self) -> int:
+        """Simulate fetching the number of planned application units from the model.
+
+        If self._planned_units is None, then we simulate what the Juju controller will do, which is
+        to report the number of peers, plus one (we include this unit in the count). This can be
+        overridden for testing purposes: a charm author can set the number of planned units
+        explicitly by calling `Harness.set_planned_units`
+        """
+        if self._planned_units is not None:
+            return self._planned_units
+
+        units: Set[str] = set()
+        peer_names: Set[str] = set(self._meta.peers.keys())
+        for peer_id, peer_name in self._relation_names.items():
+            if peer_name not in peer_names:
+                continue
+            peer_units = self._relation_list_map[peer_id]
+            units.update(peer_units)
+
+        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 self._secret_ids_are_equal(s.id, id)), None)
+
+    def _ensure_secret(self, id: str) -> _Secret:
+        secret = self._get_secret(id)
+        if secret is None:
+            raise model.SecretNotFoundError(f'Secret {id!r} not found')
+        return secret
+
+    def _ensure_secret_id_or_label(self, id: Optional[str], label: Optional[str]):
+        secret = None
+        if id is not None:
+            secret = self._get_secret(id)
+            if secret is not None and label is not None:
+                secret.label = label  # both id and label given, update label
+        if secret is None and label is not None:
+            secret = next((s for s in self._secrets if s.label == label), None)
+        if secret is None:
+            raise model.SecretNotFoundError(
+                f'Secret not found by ID ({id!r}) or label ({label!r})'
+            )
+        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,
+        *,
+        id: Optional[str] = None,
+        label: Optional[str] = None,
+        refresh: bool = False,
+        peek: bool = False,
+    ) -> Dict[str, str]:
+        secret = self._ensure_secret_id_or_label(id, label)
+
+        if secret.owner_name == self.model_uuid:
+            # This is a user secret - charms only ever have view access.
+            if self.app_name not in secret.user_secrets_grants:
+                raise model.SecretNotFoundError(
+                    f'Secret {id!r} not granted access to {self.app_name!r}'
+                )
+        elif secret.owner_name not in [self.app_name, self.unit_name]:
+            # This is a model secret - the model might have admin or view access.
+            # Check that caller has permission to get this secret
+            # Observer is calling: does secret have a grant on relation between
+            # this charm (the observer) and the secret owner's app?
+            owner_app = secret.owner_name.split('/')[0]
+            relation_id = self._relation_id_to(owner_app)
+            if relation_id is None:
+                raise model.SecretNotFoundError(
+                    f'Secret {id!r} does not have relation to {owner_app!r}'
+                )
+            grants = secret.grants.get(relation_id, set())
+            if self.app_name not in grants and self.unit_name not in grants:
+                raise model.SecretNotFoundError(
+                    f'Secret {id!r} not granted access to {self.app_name!r} or {self.unit_name!r}'
+                )
+
+        if peek or refresh:
+            revision = secret.revisions[-1]
+            if refresh:
+                secret.tracked = revision.revision
+        else:
+            revision = next((r for r in secret.revisions if r.revision == secret.tracked), None)
+            if revision is None:
+                raise model.SecretNotFoundError(f'Secret {id!r} tracked revision was removed')
+
+        return revision.content
+
+    def _relation_id_to(self, remote_app: str) -> Optional[int]:
+        """Return relation ID of relation from charm's app to remote app."""
+        for relation_id, app_units in self._relation_app_and_units.items():
+            if app_units['app'] == remote_app:
+                return relation_id
+        return None
+
+    def _has_secret_owner_permission(self, secret: _Secret) -> bool:
+        # For unit secrets, the owner unit has manage permissions. For app
+        # secrets, the leader has manage permissions and other units only have
+        # view permissions.
+        # https://discourse.charmhub.io/t/secret-access-permissions/12627
+        # For user secrets the secret owner is the model, that is,
+        # when `secret.owner_name == self.model.uuid`, only model admins have
+        # manage permissions: https://juju.is/docs/juju/secret.
+
+        unit_secret = secret.owner_name == self.unit_name
+        app_secret = secret.owner_name == self.app_name
+
+        if unit_secret or (app_secret and self.is_leader()):
+            return True
+        return False
+
+    def secret_info_get(
+        self, *, id: Optional[str] = None, label: Optional[str] = None
+    ) -> model.SecretInfo:
+        secret = self._ensure_secret_id_or_label(id, label)
+        if not self._has_secret_owner_permission(secret):
+            raise model.SecretNotFoundError(
+                f'You must own secret {secret.id!r} to perform this operation'
+            )
+
+        rotates = None
+        rotation = None
+        if secret.rotate_policy is not None:
+            rotation = model.SecretRotate(secret.rotate_policy)
+            if secret.rotate_policy != model.SecretRotate.NEVER:
+                # Just set a fake rotation time some time in the future
+                rotates = datetime.datetime.now() + datetime.timedelta(days=1)
+
+        return model.SecretInfo(
+            id=secret.id,
+            label=secret.label,
+            revision=secret.tracked,
+            expires=secret.expire_time,
+            rotation=rotation,
+            rotates=rotates,
+            description=secret.description,
+            model_uuid=self.model_uuid,
+        )
+
+    def secret_set(
+        self,
+        id: str,
+        *,
+        content: Optional[Dict[str, str]] = None,
+        label: Optional[str] = None,
+        description: Optional[str] = None,
+        expire: Optional[datetime.datetime] = None,
+        rotate: Optional[model.SecretRotate] = None,
+    ) -> None:
+        secret = self._ensure_secret(id)
+        if not self._has_secret_owner_permission(secret):
+            raise RuntimeError(f'You must own secret {secret.id!r} to perform this operation')
+
+        if content is None:
+            content = secret.revisions[-1].content
+        revision = _SecretRevision(revision=secret.revisions[-1].revision + 1, content=content)
+        secret.revisions.append(revision)
+        if label is not None:
+            if label:
+                secret.label = label
+            else:
+                secret.label = None  # clear label
+        if description is not None:
+            if description:
+                secret.description = description
+            else:
+                secret.description = None  # clear description
+        if expire is not None:
+            secret.expire_time = expire
+        if rotate is not None:
+            if rotate != model.SecretRotate.NEVER:
+                secret.rotate_policy = rotate.value
+            else:
+                secret.rotate_policy = None  # clear rotation policy
+
+    @classmethod
+    def _generate_secret_id(cls) -> str:
+        # Not a proper Juju secrets-style xid, but that's okay
+        return f'secret:{uuid.uuid4()}'
+
+    def secret_add(
+        self,
+        content: Dict[str, str],
+        *,
+        label: Optional[str] = None,
+        description: Optional[str] = None,
+        expire: Optional[datetime.datetime] = None,
+        rotate: Optional[model.SecretRotate] = None,
+        owner: Optional[str] = None,
+    ) -> str:
+        owner_name = self.unit_name if owner == 'unit' else self.app_name
+        return self._secret_add(
+            content, owner_name, label=label, description=description, expire=expire, rotate=rotate
+        )
+
+    def _secret_add(
+        self,
+        content: Dict[str, str],
+        owner_name: str,
+        *,
+        label: Optional[str] = None,
+        description: Optional[str] = None,
+        expire: Optional[datetime.datetime] = None,
+        rotate: Optional[model.SecretRotate] = None,
+    ) -> str:
+        id = self._generate_secret_id()
+        revision = _SecretRevision(
+            revision=1,
+            content=content,
+        )
+        secret = _Secret(
+            id=id,
+            owner_name=owner_name,
+            revisions=[revision],
+            rotate_policy=rotate.value if rotate is not None else None,
+            expire_time=expire,
+            label=label,
+            description=description,
+        )
+        self._secrets.append(secret)
+        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)
+        if not self._has_secret_owner_permission(secret):
+            raise model.SecretNotFoundError(
+                f'You must own secret {secret.id!r} to perform this operation'
+            )
+
+        if relation_id not in secret.grants:
+            secret.grants[relation_id] = set()
+        remote_app_name = self._relation_app_and_units[relation_id]['app']
+        secret.grants[relation_id].add(unit or remote_app_name)
+
+    def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None) -> None:
+        secret = self._ensure_secret(id)
+        if not self._has_secret_owner_permission(secret):
+            raise RuntimeError(f'You must own secret {secret.id!r} to perform this operation')
+
+        if relation_id not in secret.grants:
+            return
+        remote_app_name = self._relation_app_and_units[relation_id]['app']
+        secret.grants[relation_id].discard(unit or remote_app_name)
+
+    def secret_remove(self, id: str, *, revision: Optional[int] = None) -> None:
+        secret = self._ensure_secret(id)
+        if not self._has_secret_owner_permission(secret):
+            raise RuntimeError(f'You must own secret {secret.id!r} to perform this operation')
+
+        if revision is not None:
+            revisions = [r for r in secret.revisions if r.revision != revision]
+            if len(revisions) == len(secret.revisions):
+                raise model.SecretNotFoundError(f'Secret {id!r} revision {revision} not found')
+            if revisions:
+                secret.revisions = revisions
+            else:
+                # Last revision removed, remove entire secret
+                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 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)
+        protocol_lit = cast(Literal['tcp', 'udp', 'icmp'], protocol)
+        self._opened_ports.add(model.Port(protocol_lit, port))
+
+    def close_port(self, protocol: str, port: Optional[int] = None):
+        self._check_protocol_and_port(protocol, port)
+        protocol_lit = cast(Literal['tcp', 'udp', 'icmp'], protocol)
+        self._opened_ports.discard(model.Port(protocol_lit, port))
+
+    def opened_ports(self) -> Set[model.Port]:
+        return set(self._opened_ports)
+
+    def _check_protocol_and_port(self, protocol: str, port: Optional[int]):
+        # Simulate the error messages we get from Juju (not that charm tests
+        # should be testing details of error messages).
+        if protocol == 'icmp':
+            if port is not None:
+                raise model.ModelError(
+                    f'ERROR protocol "{protocol}" doesn\'t support any ports; got "{port}"\n'
+                )
+        elif protocol in ['tcp', 'udp']:
+            if port is None:
+                raise model.ModelError(
+                    f'ERROR invalid port "{protocol}": '
+                    f'strconv.Atoi: parsing "{protocol}": invalid syntax\n'
+                )
+            if not (1 <= port <= 65535):
+                raise model.ModelError(
+                    f'ERROR port range bounds must be between 1 and 65535, got {port}-{port}\n'
+                )
+        else:
+            raise model.ModelError(
+                f'ERROR invalid protocol "{protocol}", expected "tcp", "udp", or "icmp"\n'
+            )
+
+    def reboot(self, now: bool = False):
+        self._reboot_count += 1
+        if not now:
+            return
+        # This should exit, reboot, and re-emit the event, but we'll need the caller
+        # to handle everything after the exit.
+        raise SystemExit()
+
+    def credential_get(self) -> model.CloudSpec:
+        if not self._cloud_spec:
+            raise model.ModelError(
+                'ERROR cloud spec is empty, set it with `Harness.set_cloud_spec()` first'
+            )
+        return self._cloud_spec
+
+
+@_copy_docstrings(pebble.ExecProcess)
+class _TestingExecProcess:
+    def __init__(
+        self,
+        command: List[str],
+        timeout: Optional[float],
+        exit_code: Optional[int],
+        stdin: Union[TextIO, BinaryIO, None],
+        stdout: Union[TextIO, BinaryIO, None],
+        stderr: Union[TextIO, BinaryIO, None],
+        is_timeout: bool,
+    ):
+        self._command = command
+        self._timeout = timeout
+        self._is_timeout = is_timeout
+        if exit_code is None and not is_timeout:
+            raise ValueError('when is_timeout is False, exit_code must not be None')
+        self._exit_code = exit_code
+        self.stdin = stdin
+        self.stdout = stdout
+        self.stderr = stderr
+
+    def wait(self):
+        if self._is_timeout:
+            raise pebble.TimeoutError(f'timed out waiting for change ({self._timeout} seconds)')
+        if self._exit_code != 0:
+            raise pebble.ExecError(self._command, cast(int, self._exit_code), None, None)
+
+    def wait_output(self) -> Tuple[AnyStr, Optional[AnyStr]]:
+        if self._is_timeout:
+            raise pebble.TimeoutError(f'timed out waiting for change ({self._timeout} seconds)')
+        out_value = self.stdout.read() if self.stdout is not None else None
+        err_value = self.stderr.read() if self.stderr is not None else None
+        if self._exit_code != 0:
+            raise pebble.ExecError[AnyStr](
+                self._command,
+                cast(int, self._exit_code),
+                cast(Union[AnyStr, None], out_value),
+                cast(Union[AnyStr, None], err_value),
+            )
+        return cast(AnyStr, out_value), cast(Union[AnyStr, None], err_value)
+
+    def send_signal(self, sig: Union[int, str]):
+        # the process is always terminated when ExecProcess is return in the simulation.
+        raise BrokenPipeError('[Errno 32] Broken pipe')
+
+
+@_copy_docstrings(pebble.Client)
+class _TestingPebbleClient:
+    """This conforms to the interface for pebble.Client but provides canned data.
+
+    DO NOT use this class directly, it is used by `Harness`_ to run interactions with Pebble.
+    `Harness`_ is responsible for maintaining the internal consistency of the values here,
+    as the only public methods of this type are for implementing Client.
+    """
+
+    def __init__(self, backend: _TestingModelBackend, container_root: pathlib.Path):
+        self._backend = _TestingModelBackend
+        self._layers: Dict[str, pebble.Layer] = {}
+        # Has a service been started/stopped?
+        self._service_status: Dict[str, pebble.ServiceStatus] = {}
+        self._root = container_root
+        self._backend = backend
+        self._exec_handlers: Dict[Tuple[str, ...], ExecHandler] = {}
+        self._notices: Dict[Tuple[str, str], pebble.Notice] = {}
+        self._last_notice_id = 0
+        self._changes: Dict[str, pebble.Change] = {}
+        self._check_infos: Dict[str, pebble.CheckInfo] = {}
+
+    def _handle_exec(self, command_prefix: Sequence[str], handler: ExecHandler):
+        prefix = tuple(command_prefix)
+        self._exec_handlers[prefix] = handler
+
+    def _check_connection(self):
+        if not self._backend._can_connect(self):
+            msg = (
+                'Cannot connect to Pebble; did you forget to call '
+                'begin_with_initial_hooks() or set_can_connect()?'
+            )
+            raise pebble.ConnectionError(msg)
+
+    def get_system_info(self) -> pebble.SystemInfo:
+        self._check_connection()
+        return pebble.SystemInfo(version='1.0.0')
+
+    def get_warnings(
+        self,
+        select: pebble.WarningState = pebble.WarningState.PENDING,
+    ) -> List['pebble.Warning']:
+        raise NotImplementedError(self.get_warnings)
+
+    def ack_warnings(self, timestamp: datetime.datetime) -> int:
+        raise NotImplementedError(self.ack_warnings)
+
+    def get_changes(
+        self,
+        select: pebble.ChangeState = pebble.ChangeState.IN_PROGRESS,
+        service: Optional[str] = None,
+    ) -> List[pebble.Change]:
+        raise NotImplementedError(self.get_changes)
+
+    def get_change(self, change_id: str) -> pebble.Change:
+        self._check_connection()
+        try:
+            return self._changes[change_id]
+        except KeyError:
+            message = f'cannot find change with id "{change_id}"'
+            raise self._api_error(404, message) from None
+
+    def abort_change(self, change_id: pebble.ChangeID) -> pebble.Change:
+        raise NotImplementedError(self.abort_change)
+
+    def autostart_services(self, timeout: float = 30.0, delay: float = 0.1):
+        self._check_connection()
+        for name, service in self._render_services().items():
+            # TODO: jam 2021-04-20 This feels awkward that Service.startup might be a string or
+            #  might be an enum. Probably should make Service.startup a property rather than an
+            #  attribute.
+            if service.startup == '':
+                startup = pebble.ServiceStartup.DISABLED
+            else:
+                startup = pebble.ServiceStartup(service.startup)
+            if startup == pebble.ServiceStartup.ENABLED:
+                self._service_status[name] = pebble.ServiceStatus.ACTIVE
+
+    def replan_services(self, timeout: float = 30.0, delay: float = 0.1):
+        return self.autostart_services(timeout, delay)
+
+    def start_services(
+        self,
+        services: List[str],
+        timeout: float = 30.0,
+        delay: float = 0.1,
+    ):
+        # A common mistake is to pass just the name of a service, rather than a list of services,
+        # so trap that so it is caught quickly.
+        if isinstance(services, str):
+            raise TypeError(f'start_services should take a list of names, not just "{services}"')
+
+        self._check_connection()
+
+        # Note: jam 2021-04-20 We don't implement ChangeID, but the default caller of this is
+        # Container.start() which currently ignores the return value
+        known_services = self._render_services()
+        # Names appear to be validated before any are activated, so do two passes
+        for name in services:
+            if name not in known_services:
+                # TODO: jam 2021-04-20 This needs a better error type
+                raise RuntimeError(f'400 Bad Request: service "{name}" does not exist')
+        for name in services:
+            self._service_status[name] = pebble.ServiceStatus.ACTIVE
+
+    def stop_services(
+        self,
+        services: List[str],
+        timeout: float = 30.0,
+        delay: float = 0.1,
+    ):
+        # handle a common mistake of passing just a name rather than a list of names
+        if isinstance(services, str):
+            raise TypeError(f'stop_services should take a list of names, not just "{services}"')
+
+        self._check_connection()
+
+        # Note: jam 2021-04-20 We don't implement ChangeID, but the default caller of this is
+        # Container.stop() which currently ignores the return value
+        known_services = self._render_services()
+        for name in services:
+            if name not in known_services:
+                # TODO: jam 2021-04-20 This needs a better error type
+                #  400 Bad Request: service "bal" does not exist
+                raise RuntimeError(f'400 Bad Request: service "{name}" does not exist')
+        for name in services:
+            self._service_status[name] = pebble.ServiceStatus.INACTIVE
+
+    def restart_services(
+        self,
+        services: List[str],
+        timeout: float = 30.0,
+        delay: float = 0.1,
+    ):
+        # handle a common mistake of passing just a name rather than a list of names
+        if isinstance(services, str):
+            raise TypeError(f'restart_services should take a list of names, not just "{services}"')
+
+        self._check_connection()
+
+        # Note: jam 2021-04-20 We don't implement ChangeID, but the default caller of this is
+        # Container.restart() which currently ignores the return value
+        known_services = self._render_services()
+        for name in services:
+            if name not in known_services:
+                # TODO: jam 2021-04-20 This needs a better error type
+                #  400 Bad Request: service "bal" does not exist
+                raise RuntimeError(f'400 Bad Request: service "{name}" does not exist')
+        for name in services:
+            self._service_status[name] = pebble.ServiceStatus.ACTIVE
+
+    def wait_change(
+        self,
+        change_id: pebble.ChangeID,
+        timeout: float = 30.0,
+        delay: float = 0.1,
+    ) -> pebble.Change:
+        raise NotImplementedError(self.wait_change)
+
+    def add_layer(
+        self,
+        label: str,
+        layer: Union[str, 'pebble.LayerDict', pebble.Layer],
+        *,
+        combine: bool = False,
+    ):
+        # I wish we could combine some of this helpful object corralling with the actual backend,
+        # rather than having to re-implement it. Maybe we could subclass
+        if not isinstance(label, str):
+            raise TypeError(f'label must be a str, not {type(label).__name__}')
+
+        if isinstance(layer, (str, dict)):
+            layer_obj = pebble.Layer(layer)
+        elif isinstance(layer, pebble.Layer):
+            layer_obj = layer
+        else:
+            raise TypeError(
+                f'layer must be str, dict, or pebble.Layer, not {type(layer).__name__}'
+            )
+
+        self._check_connection()
+
+        if label in self._layers:
+            if not combine:
+                raise RuntimeError(f'400 Bad Request: layer "{label}" already exists')
+            layer = self._layers[label]
+
+            for name, service in layer_obj.services.items():
+                # 'override' is actually single quoted in the real error, but
+                # it shouldn't be, hopefully that gets cleaned up.
+                if not service.override:
+                    raise RuntimeError(
+                        f'500 Internal Server Error: layer "{label}" must define'
+                        f'"override" for service "{name}"'
+                    )
+                if service.override not in ('merge', 'replace'):
+                    raise RuntimeError(
+                        f'500 Internal Server Error: layer "{label}" has invalid '
+                        f'"override" value on service "{name}"'
+                    )
+                elif service.override == 'replace':
+                    layer.services[name] = service
+                elif service.override == 'merge':
+                    if combine and name in layer.services:
+                        layer.services[name]._merge(service)
+                    else:
+                        layer.services[name] = service
+
+            for name, check in layer_obj.checks.items():
+                if not check.override:
+                    raise RuntimeError(
+                        f'500 Internal Server Error: layer "{label}" must define'
+                        f'"override" for check "{name}"'
+                    )
+                if check.override not in ('merge', 'replace'):
+                    raise RuntimeError(
+                        f'500 Internal Server Error: layer "{label}" has invalid '
+                        f'"override" value for check "{name}"'
+                    )
+                elif check.override == 'replace':
+                    layer.checks[name] = check
+                elif check.override == 'merge':
+                    if combine and name in layer.checks:
+                        layer.checks[name]._merge(check)
+                    else:
+                        layer.checks[name] = check
+
+            for name, log_target in layer_obj.log_targets.items():
+                if not log_target.override:
+                    raise RuntimeError(
+                        f'500 Internal Server Error: layer "{label}" must define'
+                        f'"override" for log target "{name}"'
+                    )
+                if log_target.override not in ('merge', 'replace'):
+                    raise RuntimeError(
+                        f'500 Internal Server Error: layer "{label}" has invalid '
+                        f'"override" value for log target "{name}"'
+                    )
+                elif log_target.override == 'replace':
+                    layer.log_targets[name] = log_target
+                elif log_target.override == 'merge':
+                    if combine and name in layer.log_targets:
+                        layer.log_targets[name]._merge(log_target)
+                    else:
+                        layer.log_targets[name] = log_target
+
+        else:
+            self._layers[label] = layer_obj
+
+    def _render_services(self) -> Dict[str, pebble.Service]:
+        services: Dict[str, pebble.Service] = {}
+        for key in sorted(self._layers.keys()):
+            layer = self._layers[key]
+            for name, service in layer.services.items():
+                # TODO: merge existing services https://github.com/canonical/operator/issues/1112
+                services[name] = service
+        return services
+
+    def _render_checks(self) -> Dict[str, pebble.Check]:
+        checks: Dict[str, pebble.Check] = {}
+        for key in sorted(self._layers.keys()):
+            layer = self._layers[key]
+            for name, check in layer.checks.items():
+                checks[name] = check
+        return checks
+
+    def _render_log_targets(self) -> Dict[str, pebble.LogTarget]:
+        log_targets: Dict[str, pebble.LogTarget] = {}
+        for key in sorted(self._layers.keys()):
+            layer = self._layers[key]
+            for name, log_target in layer.log_targets.items():
+                log_targets[name] = log_target
+        return log_targets
+
+    def get_plan(self) -> pebble.Plan:
+        self._check_connection()
+        plan = pebble.Plan('{}')
+        plan.services.update(self._render_services())
+        plan.checks.update(self._render_checks())
+        plan.log_targets.update(self._render_log_targets())
+        return plan
+
+    def get_services(self, names: Optional[List[str]] = None) -> List[pebble.ServiceInfo]:
+        if isinstance(names, str):
+            raise TypeError(f'start_services should take a list of names, not just "{names}"')
+
+        self._check_connection()
+        services = self._render_services()
+        infos: List[pebble.ServiceInfo] = []
+        if names is None:
+            names = sorted(services.keys())
+        for name in sorted(names):
+            try:
+                service = services[name]
+            except KeyError:
+                # in pebble, it just returns "nothing matched" if there are 0 matches,
+                # but it ignores services it doesn't recognize
+                continue
+            status = self._service_status.get(name, pebble.ServiceStatus.INACTIVE)
+            if service.startup == '':
+                startup = pebble.ServiceStartup.DISABLED
+            else:
+                startup = pebble.ServiceStartup(service.startup)
+            info = pebble.ServiceInfo(name, startup=startup, current=pebble.ServiceStatus(status))
+            infos.append(info)
+        return infos
+
+    @staticmethod
+    def _check_absolute_path(path: str):
+        if not path.startswith('/'):
+            raise pebble.PathError('generic-file-error', f'paths must be absolute, got {path!r}')
+
+    def pull(self, path: str, *, encoding: Optional[str] = 'utf-8') -> Union[BinaryIO, TextIO]:
+        self._check_connection()
+        self._check_absolute_path(path)
+        file_path = self._root / path[1:]
+        try:
+            return cast(
+                Union[BinaryIO, TextIO],
+                file_path.open('rb' if encoding is None else 'r', encoding=encoding),
+            )
+        except FileNotFoundError:
+            raise pebble.PathError(
+                'not-found', f'stat {path}: no such file or directory'
+            ) from None
+        except IsADirectoryError:
+            raise pebble.PathError(
+                'generic-file-error', f'can only read a regular file: "{path}"'
+            ) from None
+
+    def push(
+        self,
+        path: str,
+        source: 'ReadableBuffer',
+        *,
+        encoding: str = 'utf-8',
+        make_dirs: bool = False,
+        permissions: Optional[int] = None,
+        user_id: Optional[int] = None,
+        user: Optional[str] = None,
+        group_id: Optional[int] = None,
+        group: Optional[str] = None,
+    ) -> None:
+        self._check_connection()
+        if permissions is not None and not (0 <= permissions <= 0o777):
+            raise pebble.PathError(
+                'generic-file-error', f'permissions not within 0o000 to 0o777: {permissions:#o}'
+            )
+        self._check_absolute_path(path)
+        file_path = self._root / path[1:]
+        if make_dirs and not file_path.parent.exists():
+            self.make_dir(
+                os.path.dirname(path),
+                make_parents=True,
+                permissions=None,
+                user_id=user_id,
+                user=user,
+                group_id=group_id,
+                group=group,
+            )
+        permissions = permissions if permissions is not None else 0o644
+        try:
+            if isinstance(source, str):
+                file_path.write_text(source, encoding=encoding)
+            elif isinstance(source, bytes):
+                file_path.write_bytes(source)
+            else:
+                # If source is binary, open file in binary mode and ignore encoding param
+                is_binary = isinstance(source.read(0), bytes)  # type: ignore
+                open_mode = 'wb' if is_binary else 'w'
+                open_encoding = None if is_binary else encoding
+                with file_path.open(open_mode, encoding=open_encoding) as f:
+                    shutil.copyfileobj(cast(IOBase, source), cast(IOBase, f))
+            os.chmod(file_path, permissions)
+        except FileNotFoundError as e:
+            raise pebble.PathError(
+                'not-found', f'parent directory not found: {e.args[0]}'
+            ) from None
+        except NotADirectoryError:
+            raise pebble.PathError(
+                'generic-file-error', f'open {path}.~: not a directory'
+            ) from None
+
+    def list_files(
+        self, path: str, *, pattern: Optional[str] = None, itself: bool = False
+    ) -> List[pebble.FileInfo]:
+        self._check_connection()
+        self._check_absolute_path(path)
+        file_path = self._root / path[1:]
+        if not file_path.exists():
+            raise self._api_error(404, f'stat {path}: no such file or directory')
+        files = [file_path]
+        if not itself:
+            try:
+                files = [file_path / file for file in os.listdir(file_path)]
+            except NotADirectoryError:
+                pass
+
+        if pattern is not None:
+            files = [file for file in files if fnmatch.fnmatch(file.name, pattern)]
+
+        file_infos = [Container._build_fileinfo(file) for file in files]
+        for file_info in file_infos:
+            rel_path = os.path.relpath(file_info.path, start=self._root)
+            rel_path = '/' if rel_path == '.' else '/' + rel_path
+            file_info.path = rel_path
+            if rel_path == '/':
+                file_info.name = '/'
+        return file_infos
+
+    def make_dir(
+        self,
+        path: str,
+        *,
+        make_parents: bool = False,
+        permissions: Optional[int] = None,
+        user_id: Optional[int] = None,
+        user: Optional[str] = None,
+        group_id: Optional[int] = None,
+        group: Optional[str] = None,
+    ) -> None:
+        self._check_connection()
+        if permissions is not None and not (0 <= permissions <= 0o777):
+            raise pebble.PathError(
+                'generic-file-error', f'permissions not within 0o000 to 0o777: {permissions:#o}'
+            )
+        self._check_absolute_path(path)
+        dir_path = self._root / path[1:]
+        if not dir_path.parent.exists() and not make_parents:
+            raise pebble.PathError('not-found', f'parent directory not found: {path}')
+        if not dir_path.parent.exists() and make_parents:
+            self.make_dir(
+                os.path.dirname(path),
+                make_parents=True,
+                permissions=permissions,
+                user_id=user_id,
+                user=user,
+                group_id=group_id,
+                group=group,
+            )
+        try:
+            permissions = permissions if permissions else 0o755
+            dir_path.mkdir()
+            os.chmod(dir_path, permissions)
+        except FileExistsError:
+            if not make_parents:
+                raise pebble.PathError(
+                    'generic-file-error', f'mkdir {path}: file exists'
+                ) from None
+        except NotADirectoryError as e:
+            # Attempted to create a subdirectory of a file
+            raise pebble.PathError('generic-file-error', f'not a directory: {e.args[0]}') from None
+
+    def remove_path(self, path: str, *, recursive: bool = False):
+        self._check_connection()
+        self._check_absolute_path(path)
+        file_path = self._root / path[1:]
+        if not file_path.exists():
+            if recursive:
+                return
+            raise pebble.PathError('not-found', f'remove {path}: no such file or directory')
+        if file_path.is_dir():
+            if recursive:
+                shutil.rmtree(file_path)
+            else:
+                try:
+                    file_path.rmdir()
+                except OSError as e:
+                    raise pebble.PathError(
+                        'generic-file-error',
+                        'cannot remove non-empty directory without recursive=True',
+                    ) from e
+        else:
+            file_path.unlink()
+
+    def _find_exec_handler(self, command: List[str]) -> Optional[ExecHandler]:
+        for prefix_len in reversed(range(len(command) + 1)):
+            command_prefix = tuple(command[:prefix_len])
+            if command_prefix in self._exec_handlers:
+                return self._exec_handlers[command_prefix]
+        return None
+
+    def _transform_exec_handler_output(
+        self, data: Union[str, bytes], encoding: Optional[str]
+    ) -> Union[io.BytesIO, io.StringIO]:
+        if isinstance(data, bytes):
+            if encoding is None:
+                return io.BytesIO(data)
+            else:
+                return io.StringIO(data.decode(encoding=encoding))
+        else:
+            if encoding is None:
+                raise ValueError(
+                    f'exec handler must return bytes if encoding is None,'
+                    f'not {data.__class__.__name__}'
+                )
+            else:
+                return io.StringIO(typing.cast(str, data))
+
+    def exec(
+        self,
+        command: List[str],
+        *,
+        service_context: Optional[str] = None,
+        environment: Optional[Dict[str, str]] = None,
+        working_dir: Optional[str] = None,
+        timeout: Optional[float] = None,
+        user_id: Optional[int] = None,
+        user: Optional[str] = None,
+        group_id: Optional[int] = None,
+        group: Optional[str] = None,
+        stdin: Optional[Union[str, bytes, TextIO, BinaryIO]] = None,
+        stdout: Optional[Union[TextIO, BinaryIO]] = None,
+        stderr: Optional[Union[TextIO, BinaryIO]] = None,
+        encoding: Optional[str] = 'utf-8',
+        combine_stderr: bool = False,
+    ) -> ExecProcess[Any]:
+        self._check_connection()
+        handler = self._find_exec_handler(command)
+        if handler is None:
+            message = 'execution handler not found, please register one using Harness.handle_exec'
+            raise self._api_error(500, message)
+        environment = {} if environment is None else environment
+        if service_context is not None:
+            plan = self.get_plan()
+            if service_context not in plan.services:
+                message = f'context service "{service_context}" not found'
+                raise self._api_error(500, message)
+            service = plan.services[service_context]
+            environment = {**service.environment, **environment}
+            working_dir = service.working_dir if working_dir is None else working_dir
+            user = service.user if user is None else user
+            user_id = service.user_id if user_id is None else user_id
+            group = service.group if group is None else group
+            group_id = service.group_id if group_id is None else group_id
+
+        if hasattr(stdin, 'read'):
+            stdin = stdin.read()  # type: ignore
+
+        exec_args = ExecArgs(
+            command=command,
+            environment=environment,
+            working_dir=working_dir,
+            timeout=timeout,
+            user_id=user_id,
+            user=user,
+            group_id=group_id,
+            group=group,
+            stdin=cast(Union[str, bytes, None], stdin),
+            encoding=encoding,
+            combine_stderr=combine_stderr,
+        )
+        proc_stdin = self._transform_exec_handler_output(b'', encoding)
+        if stdin is not None:
+            proc_stdin = None
+        proc_stdout = self._transform_exec_handler_output(b'', encoding)
+        proc_stderr = self._transform_exec_handler_output(b'', encoding)
+        try:
+            result = handler(exec_args)
+        except TimeoutError:
+            if timeout is not None:
+                exec_process = _TestingExecProcess(
+                    command=command,
+                    timeout=timeout,
+                    exit_code=None,
+                    stdin=proc_stdin,
+                    stdout=proc_stdout,
+                    stderr=proc_stderr,
+                    is_timeout=True,
+                )
+                return cast(pebble.ExecProcess[Any], exec_process)
+            else:
+                raise RuntimeError(
+                    'a TimeoutError occurred in the execution handler, '
+                    'but no timeout value was provided in the execution arguments.'
+                ) from None
+        if result is None:
+            exit_code = 0
+            proc_stdout = self._transform_exec_handler_output(b'', encoding)
+            proc_stderr = self._transform_exec_handler_output(b'', encoding)
+        elif isinstance(result, ExecResult):
+            exit_code = result.exit_code
+            proc_stdout = self._transform_exec_handler_output(result.stdout, encoding)
+            proc_stderr = self._transform_exec_handler_output(result.stderr, encoding)
+        else:
+            raise TypeError(f'execution handler returned an unexpected type: {type(result)!r}.')
+        if combine_stderr and proc_stderr.getvalue():
+            raise ValueError(
+                'execution handler returned a non-empty stderr '
+                'even though combine_stderr is enabled.'
+            )
+        if stdout is not None:
+            shutil.copyfileobj(cast(io.IOBase, proc_stdout), cast(io.IOBase, stdout))
+            proc_stdout = None
+        if stderr is not None:
+            shutil.copyfileobj(cast(io.IOBase, proc_stderr), cast(io.IOBase, stderr))
+            proc_stderr = None
+        exec_process = _TestingExecProcess(
+            command=command,
+            timeout=timeout,
+            exit_code=exit_code,
+            stdin=proc_stdin,
+            stdout=proc_stdout,
+            stderr=proc_stderr,
+            is_timeout=False,
+        )
+        return cast(pebble.ExecProcess[Any], exec_process)
+
+    def send_signal(self, sig: Union[int, str], service_names: Iterable[str]):
+        if not service_names:
+            raise TypeError('send_signal expected at least 1 service name, got 0')
+        self._check_connection()
+
+        # Convert signal to str
+        if isinstance(sig, int):
+            sig = signal.Signals(sig).name
+
+        # pebble first validates the service name, and then the signal name
+
+        plan = self.get_plan()
+        for service in service_names:
+            if service not in plan.services or not self.get_services([service])[0].is_running():
+                # conform with the real pebble api
+                message = f'cannot send signal to "{service}": service is not running'
+                raise self._api_error(500, message)
+
+        # Check if signal name is valid
+        try:
+            signal.Signals[sig]
+        except KeyError:
+            # conform with the real pebble api
+            first_service = next(iter(service_names))
+            message = f'cannot send signal to "{first_service}": invalid signal name "{sig}"'
+            raise self._api_error(500, message) from None
+
+    def get_checks(
+        self, level: Optional[pebble.CheckLevel] = None, names: Optional[Iterable[str]] = None
+    ) -> List[pebble.CheckInfo]:
+        if names is not None:
+            names = frozenset(names)
+        return [
+            info
+            for info in self._check_infos.values()
+            if (level is None or level == info.level) and (names is None or info.name in names)
+        ]
+
+    def notify(
+        self,
+        type: pebble.NoticeType,
+        key: str,
+        *,
+        data: Optional[Dict[str, str]] = None,
+        repeat_after: Optional[datetime.timedelta] = None,
+    ) -> str:
+        notice_id, _ = self._notify(type, key, data=data, repeat_after=repeat_after)
+        return notice_id
+
+    def _notify(
+        self,
+        type: pebble.NoticeType,
+        key: str,
+        *,
+        data: Optional[Dict[str, str]] = None,
+        repeat_after: Optional[datetime.timedelta] = None,
+    ) -> Tuple[str, bool]:
+        """Record an occurrence of a notice with the specified details.
+
+        Return a tuple of (notice_id, new_or_repeated).
+        """
+        # The shape of the code below is taken from State.AddNotice in Pebble.
+        now = datetime.datetime.now(tz=datetime.timezone.utc)
+
+        new_or_repeated = False
+        unique_key = (type.value, key)
+        notice = self._notices.get(unique_key)
+        if notice is None:
+            # First occurrence of this notice uid+type+key
+            self._last_notice_id += 1
+            notice = pebble.Notice(
+                id=str(self._last_notice_id),
+                user_id=0,  # Charm should always be able to read pebble_notify notices.
+                type=type,
+                key=key,
+                first_occurred=now,
+                last_occurred=now,
+                last_repeated=now,
+                expire_after=datetime.timedelta(days=7),
+                occurrences=1,
+                last_data=data or {},
+                repeat_after=repeat_after,
+            )
+            self._notices[unique_key] = notice
+            new_or_repeated = True
+        else:
+            # Additional occurrence, update existing notice
+            last_repeated = notice.last_repeated
+            if repeat_after is None or now > notice.last_repeated + repeat_after:
+                # Update last repeated time if repeat-after time has elapsed (or is None)
+                last_repeated = now
+                new_or_repeated = True
+            notice = dataclasses.replace(
+                notice,
+                last_occurred=now,
+                last_repeated=last_repeated,
+                occurrences=notice.occurrences + 1,
+                last_data=data or {},
+                repeat_after=repeat_after,
+            )
+            self._notices[unique_key] = notice
+
+        return notice.id, new_or_repeated
+
+    def _api_error(self, code: int, message: str) -> pebble.APIError:
+        status = http.HTTPStatus(code).phrase
+        body = {
+            'type': 'error',
+            'status-code': code,
+            'status': status,
+            'result': {'message': message},
+        }
+        return pebble.APIError(body, code, status, message)
+
+    def get_notice(self, id: str) -> pebble.Notice:
+        for notice in self._notices.values():
+            if notice.id == id:
+                return notice
+        raise self._api_error(404, f'cannot find notice with ID "{id}"')
+
+    def get_notices(
+        self,
+        *,
+        users: Optional[pebble.NoticesUsers] = None,
+        user_id: Optional[int] = None,
+        types: Optional[Iterable[Union[pebble.NoticeType, str]]] = None,
+        keys: Optional[Iterable[str]] = None,
+    ) -> List[pebble.Notice]:
+        # Similar logic as api_notices.go:v1GetNotices in Pebble.
+
+        filter_user_id = 0  # default is to filter by request UID (root)
+        if user_id is not None:
+            filter_user_id = user_id
+        if users is not None:
+            if user_id is not None:
+                raise self._api_error(400, 'cannot use both "users" and "user_id"')
+            filter_user_id = None
+
+        if types is not None:
+            types = {(t.value if isinstance(t, pebble.NoticeType) else t) for t in types}
+        if keys is not None:
+            keys = set(keys)
+
+        notices = [
+            notice
+            for notice in self._notices.values()
+            if self._notice_matches(notice, filter_user_id, types, keys)
+        ]
+        notices.sort(key=lambda notice: notice.last_repeated)
+        return notices
+
+    @staticmethod
+    def _notice_matches(
+        notice: pebble.Notice,
+        user_id: Optional[int] = None,
+        types: Optional[Set[str]] = None,
+        keys: Optional[Set[str]] = None,
+    ) -> bool:
+        # Same logic as NoticeFilter.matches in Pebble.
+        # For example: if user_id filter is set and it doesn't match, return False.
+        if user_id is not None and not (notice.user_id is None or user_id == notice.user_id):
+            return False
+        if types is not None and notice.type not in types:
+            return False
+        if keys is not None and notice.key not in keys:
+            return False
+        return True
diff --git a/ops/charm.py b/ops/charm.py
index c626d11da..edef8db32 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -18,6 +18,7 @@
 import enum
 import logging
 import pathlib
+import warnings
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -359,6 +360,14 @@ class PreSeriesUpgradeEvent(HookEvent):
     .. jujuremoved:: 4.0
     """
 
+    def __init__(self, handle: 'Handle'):
+        warnings.warn(
+            'pre-series-upgrade events will not be emitted from Juju 4.0 onwards',
+            DeprecationWarning,
+            stacklevel=3,
+        )
+        super().__init__(handle)
+
 
 class PostSeriesUpgradeEvent(HookEvent):
     """Event triggered after a series upgrade.
@@ -375,6 +384,14 @@ class PostSeriesUpgradeEvent(HookEvent):
     .. jujuremoved:: 4.0
     """
 
+    def __init__(self, handle: 'Handle'):
+        warnings.warn(
+            'post-series-upgrade events will not be emitted from Juju 4.0 onwards',
+            DeprecationWarning,
+            stacklevel=3,
+        )
+        super().__init__(handle)
+
 
 class LeaderElectedEvent(HookEvent):
     """Event triggered when a new leader has been elected.
@@ -412,6 +429,15 @@ class CollectMetricsEvent(HookEvent):
     .. jujuremoved:: 4.0
     """
 
+    def __init__(self, handle: 'Handle'):
+        warnings.warn(
+            'collect-metrics events will not be emitted from Juju 4.0 onwards - '
+            'consider using the Canonical Observability Stack',
+            DeprecationWarning,
+            stacklevel=3,
+        )
+        super().__init__(handle)
+
     def add_metrics(
         self, metrics: Mapping[str, Union[int, float]], labels: Optional[Mapping[str, str]] = None
     ):
diff --git a/ops/model.py b/ops/model.py
index 7571bd2ac..335dd9aa1 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -42,6 +42,7 @@
     Generator,
     Iterable,
     List,
+    Literal,
     Mapping,
     MutableMapping,
     Optional,
@@ -51,6 +52,7 @@
     Type,
     TypedDict,
     Union,
+    get_args,
 )
 
 import ops
@@ -69,7 +71,11 @@
 _StorageDictType = Dict[str, Optional[List['Storage']]]
 _BindingDictType = Dict[Union[str, 'Relation'], 'Binding']
 
-_StatusDict = TypedDict('_StatusDict', {'status': str, 'message': str})
+_ReadOnlyStatusName = Literal['error', 'unknown']
+_SettableStatusName = Literal['active', 'blocked', 'maintenance', 'waiting']
+StatusName = Union[_SettableStatusName, _ReadOnlyStatusName]
+_StatusDict = TypedDict('_StatusDict', {'status': StatusName, 'message': str})
+_SETTABLE_STATUS_NAMES: Tuple[_SettableStatusName, ...] = get_args(_SettableStatusName)
 
 # mapping from relation name to a list of relation objects
 _RelationMapping_Raw = Dict[str, Optional[List['Relation']]]
@@ -424,9 +430,12 @@ def status(self, value: 'StatusBase'):
         if not self._backend.is_leader():
             raise RuntimeError('cannot set application status as a non-leader unit')
 
-        for _key in {'name', 'message'}:
-            assert isinstance(getattr(value, _key), str), f'status.{_key} must be a string'
-        self._backend.status_set(value.name, value.message, is_app=True)
+        self._backend.status_set(
+            typing.cast(_SettableStatusName, value.name),  # status_set will validate at runtime
+            value.message,
+            is_app=True,
+        )
+
         self._status = value
 
     def planned_units(self) -> int:
@@ -590,8 +599,11 @@ def status(self, value: 'StatusBase'):
         if not self._is_our_unit:
             raise RuntimeError(f'cannot set status for a remote unit {self}')
 
-        # fixme: if value.messages
-        self._backend.status_set(value.name, value.message, is_app=False)
+        self._backend.status_set(
+            typing.cast(_SettableStatusName, value.name),  # status_set will validate at runtime
+            value.message,
+            is_app=False,
+        )
         self._status = value
 
     def __repr__(self):
@@ -1885,10 +1897,10 @@ class StatusBase:
     directly use the child class such as :class:`ActiveStatus` to indicate their status.
     """
 
-    _statuses: Dict[str, Type['StatusBase']] = {}
+    _statuses: Dict[StatusName, Type['StatusBase']] = {}
 
-    # Subclasses should override this attribute
-    name = ''
+    # Subclasses must provide this attribute
+    name: StatusName
 
     def __init__(self, message: str = ''):
         if self.__class__ is StatusBase:
@@ -1911,7 +1923,8 @@ def from_name(cls, name: str, message: str):
         does not have an associated message.
 
         Args:
-            name: Name of the status, for example "active" or "blocked".
+            name: Name of the status, one of:
+                "active", "blocked", "maintenance", "waiting", "error", or "unknown".
             message: Message to include with the status.
 
         Raises:
@@ -1921,12 +1934,12 @@ def from_name(cls, name: str, message: str):
             # unknown is special
             return UnknownStatus()
         else:
-            return cls._statuses[name](message)
+            return cls._statuses[typing.cast(StatusName, name)](message)
 
     @classmethod
     def register(cls, child: Type['StatusBase']):
         """Register a Status for the child's name."""
-        if not isinstance(child.name, str):
+        if not (hasattr(child, 'name') and isinstance(child.name, str)):
             raise TypeError(
                 f"Can't register StatusBase subclass {child}: ",
                 'missing required `name: str` class attribute',
@@ -1960,7 +1973,7 @@ class UnknownStatus(StatusBase):
     charm has not called status-set yet.
 
     This status is read-only; trying to set unit or application status to
-    ``UnknownStatus`` will raise :class:`ModelError`.
+    ``UnknownStatus`` will raise :class:`InvalidStatusError`.
     """
 
     name = 'unknown'
@@ -1981,7 +1994,7 @@ class ErrorStatus(StatusBase):
     human intervention in order to operate correctly).
 
     This status is read-only; trying to set unit or application status to
-    ``ErrorStatus`` will raise :class:`ModelError`.
+    ``ErrorStatus`` will raise :class:`InvalidStatusError`.
     """
 
     name = 'error'
@@ -3415,13 +3428,15 @@ def status_get(self, *, is_app: bool = False) -> '_StatusDict':
         #       status-data: {}
 
         if is_app:
-            content = typing.cast(Dict[str, Dict[str, str]], content)
+            content = typing.cast(Dict[str, '_StatusDict'], content)
             app_status = content['application-status']
             return {'status': app_status['status'], 'message': app_status['message']}
         else:
             return typing.cast('_StatusDict', content)
 
-    def status_set(self, status: str, message: str = '', *, is_app: bool = False) -> None:
+    def status_set(
+        self, status: _SettableStatusName, message: str = '', *, is_app: bool = False
+    ) -> None:
         """Set a status of a unit or an application.
 
         Args:
@@ -3432,6 +3447,10 @@ def status_set(self, status: str, message: str = '', *, is_app: bool = False) ->
         """
         if not isinstance(is_app, bool):
             raise TypeError('is_app parameter must be boolean')
+        if not isinstance(message, str):
+            raise TypeError('message parameter must be a string')
+        if status not in _SETTABLE_STATUS_NAMES:
+            raise InvalidStatusError(f'status must be in {_SETTABLE_STATUS_NAMES}, not {status!r}')
         self._run('status-set', f'--application={is_app}', status, message)
 
     def storage_list(self, name: str) -> List[int]:
diff --git a/ops/testing.py b/ops/testing.py
index dcabdf346..a633be3d3 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -1,4 +1,4 @@
-# Copyright 2021 Canonical Ltd.
+# Copyright 2024 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,3823 +14,51 @@
 
 """Infrastructure to build unit tests for charms using the ops library."""
 
-import dataclasses
-import datetime
-import fnmatch
-import http
-import inspect
-import io
-import ipaddress
-import os
-import pathlib
-import random
-import re
-import shutil
-import signal
-import tempfile
-import typing
-import uuid
-import warnings
-from contextlib import contextmanager
-from io import BytesIO, IOBase, StringIO
-from textwrap import dedent
-from typing import (
-    Any,
-    AnyStr,
-    BinaryIO,
-    Callable,
-    Dict,
-    Generic,
-    Iterable,
-    List,
-    Literal,
-    Mapping,
-    Optional,
-    Sequence,
-    Set,
-    TextIO,
-    Tuple,
-    Type,
-    TypedDict,
-    TypeVar,
-    Union,
-    cast,
+from ._private.harness import (
+    ActionFailed,
+    ActionOutput,
+    AppUnitOrName,
+    CharmBase,
+    CharmMeta,
+    CharmType,
+    Container,
+    ExecArgs,
+    ExecHandler,
+    ExecProcess,
+    ExecResult,
+    Harness,
+    ReadableBuffer,
+    RelationNotFoundError,
+    RelationRole,
+    YAMLStringOrFile,
+    charm,
+    framework,
+    model,
+    pebble,
+    storage,
 )
 
-from ops import charm, framework, model, pebble, storage
-from ops._private import yaml
-from ops.charm import CharmBase, CharmMeta, RelationRole
-from ops.jujucontext import _JujuContext
-from ops.model import Container, RelationNotFoundError, _NetworkDict
-from ops.pebble import ExecProcess
-
-ReadableBuffer = Union[bytes, str, StringIO, BytesIO, BinaryIO]
-_StringOrPath = Union[str, pathlib.PurePosixPath, pathlib.Path]
-_FileKwargs = TypedDict(
-    '_FileKwargs',
-    {
-        'permissions': Optional[int],
-        'last_modified': datetime.datetime,
-        'user_id': Optional[int],
-        'user': Optional[str],
-        'group_id': Optional[int],
-        'group': Optional[str],
-    },
-)
-
-_RelationEntities = TypedDict('_RelationEntities', {'app': str, 'units': List[str]})
-
-_StatusName = Literal['unknown', 'blocked', 'active', 'maintenance', 'waiting']
-_RawStatus = TypedDict(
-    '_RawStatus',
-    {
-        'status': _StatusName,
-        'message': str,
-    },
-)
-_ConfigOption = TypedDict(
-    '_ConfigOption',
-    {
-        'type': Literal['string', 'int', 'float', 'boolean', 'secret'],
-        'description': str,
-        'default': Union[str, int, float, bool],
-    },
-)
-_RawConfig = TypedDict('_RawConfig', {'options': Dict[str, _ConfigOption]})
-
-
-# YAMLStringOrFile is something like metadata.yaml or actions.yaml. You can
-# pass in a file-like object or the string directly.
-YAMLStringOrFile = Union[str, TextIO]
-
-
-# An instance of an Application or Unit, or the name of either.
-# This is done here to avoid a scoping issue with the `model` property
-# of the Harness class below.
-AppUnitOrName = Union[str, model.Application, model.Unit]
-
-
-# CharmType represents user charms that are derived from CharmBase.
-CharmType = TypeVar('CharmType', bound=charm.CharmBase)
-
-
-@dataclasses.dataclass
-class ExecArgs:
-    """Represent arguments captured from the :meth:`ops.Container.exec` method call.
-
-    These arguments will be passed to the :meth:`Harness.handle_exec` handler function.
-    See :meth:`ops.pebble.Client.exec` for documentation of properties.
-    """
-
-    command: List[str]
-    environment: Dict[str, str]
-    working_dir: Optional[str]
-    timeout: Optional[float]
-    user_id: Optional[int]
-    user: Optional[str]
-    group_id: Optional[int]
-    group: Optional[str]
-    stdin: Optional[Union[str, bytes]]
-    encoding: Optional[str]
-    combine_stderr: bool
-
-
-@dataclasses.dataclass
-class ExecResult:
-    """Represents the result of a simulated process execution.
-
-    This class is typically used to return the output and exit code from the
-    :meth:`Harness.handle_exec` result or handler function.
-    """
-
-    exit_code: int = 0
-    stdout: Union[str, bytes] = b''
-    stderr: Union[str, bytes] = b''
-
-
-ExecHandler = Callable[[ExecArgs], Union[None, ExecResult]]
-
-
-@dataclasses.dataclass(frozen=True)
-class ActionOutput:
-    """Contains the logs and results from a :meth:`Harness.run_action` call."""
-
-    logs: List[str]
-    """Messages generated by the Charm using :meth:`ops.ActionEvent.log`."""
-
-    results: Dict[str, Any]
-    """The action's results, as set or updated by :meth:`ops.ActionEvent.set_results`."""
-
-
-class ActionFailed(Exception):  # noqa
-    """Raised when :code:`event.fail()` is called during a :meth:`Harness.run_action` call."""
-
-    message: str
-    """Optional details of the failure, as provided by :meth:`ops.ActionEvent.fail`."""
-
-    output: ActionOutput
-    """Any logs and results set by the Charm."""
-
-    def __init__(self, message: str, output: ActionOutput):
-        self.message = message
-        self.output = output
-
-    def __str__(self):
-        if self.message:
-            return self.message
-        return 'Event handler called `fail()` with no additional details.'
-
-
-@dataclasses.dataclass()
-class _RunningAction:
-    name: str
-    output: ActionOutput
-    parameters: Dict[str, Any]
-    failure_message: Optional[str] = None
-
-
-# noinspection PyProtectedMember
-class Harness(Generic[CharmType]):
-    """This class represents a way to build up the model that will drive a test suite.
-
-    The model created is from the viewpoint of the charm that is being tested.
-
-    Always call ``harness.cleanup()`` after creating a :class:`Harness`::
-
-        @pytest.fixture()
-        def harness():
-            harness = Harness(MyCharm)
-            yield harness
-            harness.cleanup()
-
-    Below is an example test using :meth:`begin_with_initial_hooks` that ensures
-    the charm responds correctly to config changes (the parameter ``harness`` in the
-    test function is a pytest fixture that does setup/teardown, see :class:`Harness`)::
-
-        def test_foo(harness):
-            # Instantiate the charm and trigger events that Juju would on startup
-            harness.begin_with_initial_hooks()
-
-            # Update charm config and trigger config-changed
-            harness.update_config({'log_level': 'warn'})
-
-            # Check that charm properly handled config-changed, for example,
-            # the charm added the correct Pebble layer
-            plan = harness.get_container_pebble_plan('prometheus')
-            assert '--log.level=warn' in plan.services['prometheus'].command
-
-    To set up the model without triggering events (or calling charm code), perform the
-    harness actions before calling :meth:`begin`. Below is an example that adds a
-    relation before calling ``begin``, and then updates config to trigger the
-    ``config-changed`` event in the charm (the parameter ``harness`` in the test function
-    is a pytest fixture that does setup/teardown, see :class:`Harness`)::
-
-        def test_bar(harness):
-            # Set up model before "begin" (no events triggered)
-            harness.set_leader(True)
-            harness.add_relation('db', 'postgresql', unit_data={'key': 'val'})
-
-            # Now instantiate the charm to start triggering events as the model changes
-            harness.begin()
-            harness.update_config({'some': 'config'})
-
-            # Check that charm has properly handled config-changed, for example,
-            # has written the app's config file
-            root = harness.get_filesystem_root('container')
-            assert (root / 'etc' / 'app.conf').exists()
-
-    Args:
-        charm_cls: The Charm class to test.
-        meta: A string or file-like object containing the contents of
-            ``metadata.yaml``. If not supplied, we will look for a ``metadata.yaml`` file in the
-            parent directory of the Charm, and if not found fall back to a trivial
-            ``name: test-charm`` metadata.
-        actions: A string or file-like object containing the contents of
-            ``actions.yaml``. If not supplied, we will look for an ``actions.yaml`` file in the
-            parent directory of the Charm.
-        config: A string or file-like object containing the contents of
-            ``config.yaml``. If not supplied, we will look for a ``config.yaml`` file in the
-            parent directory of the Charm.
-    """
-
-    def __init__(
-        self,
-        charm_cls: Type[CharmType],
-        *,
-        meta: Optional[YAMLStringOrFile] = None,
-        actions: Optional[YAMLStringOrFile] = None,
-        config: Optional[YAMLStringOrFile] = None,
-    ):
-        context_environ = os.environ.copy()
-        if 'JUJU_VERSION' not in context_environ:
-            context_environ['JUJU_VERSION'] = '0.0.0'
-        self._juju_context = _JujuContext.from_dict(context_environ)
-        self._charm_cls = charm_cls
-        self._charm: Optional[CharmType] = None
-        self._charm_dir = 'no-disk-path'  # this may be updated by _create_meta
-        self._meta = self._create_meta(meta, actions)
-        self._unit_name: str = f'{self._meta.name}/0'
-        self._hooks_enabled: bool = True
-        self._relation_id_counter: int = 0
-        self._action_id_counter: int = 0
-        config_ = self._get_config(config)
-        self._backend = _TestingModelBackend(
-            self._unit_name, self._meta, config_, self._juju_context
-        )
-        self._model = model.Model(self._meta, self._backend)
-        self._storage = storage.SQLiteStorage(':memory:')
-        self._framework = framework.Framework(
-            self._storage,
-            self._charm_dir,
-            self._meta,
-            self._model,
-            juju_debug_at=self._juju_context.debug_at,
-        )
-
-    def _event_context(self, event_name: str):
-        """Configures the Harness to behave as if an event hook were running.
-
-        This means that the Harness will perform strict access control of relation data.
-
-        Example usage:
-
-        # this is how we test that attempting to write a remote app's
-        # databag will raise RelationDataError.
-        >>> with harness._event_context('foo'):
-        >>>     with pytest.raises(ops.model.RelationDataError):
-        >>>         my_relation.data[remote_app]['foo'] = 'bar'
-
-        # this is how we test with 'realistic conditions' how an event handler behaves
-        # when we call it directly -- i.e. without going through harness.add_relation
-        >>> def test_foo():
-        >>>     class MyCharm:
-        >>>         ...
-        >>>         def event_handler(self, event):
-        >>>             # this is expected to raise an exception
-        >>>             event.relation.data[event.relation.app]['foo'] = 'bar'
-        >>>
-        >>>     harness = Harness(MyCharm)
-        >>>     event = MagicMock()
-        >>>     event.relation = harness.charm.model.relations[0]
-        >>>
-        >>>     with harness._event_context('my_relation_joined'):
-        >>>         with pytest.raises(ops.model.RelationDataError):
-        >>>             harness.charm.event_handler(event)
-
-
-        If event_name == '', conversely, the Harness will believe that no hook
-        is running, allowing temporary unrestricted access to read/write a relation's
-        databags even from inside an event handler.
-        >>> def test_foo():
-        >>>     class MyCharm:
-        >>>         ...
-        >>>         def event_handler(self, event):
-        >>>             # this is expected to raise an exception since we're not leader
-        >>>             event.relation.data[self.app]['foo'] = 'bar'
-        >>>
-        >>>     harness = Harness(MyCharm)
-        >>>     event = MagicMock()
-        >>>     event.relation = harness.charm.model.relations[0]
-        >>>
-        >>>     with harness._event_context('my_relation_joined'):
-        >>>         harness.charm.event_handler(event)
-
-        """
-        return self._framework._event_context(event_name)
-
-    def set_can_connect(self, container: Union[str, model.Container], val: bool):
-        """Change the simulated connection status of a container's underlying Pebble client.
-
-        After calling this, :meth:`ops.Container.can_connect` will return val.
-        """
-        if isinstance(container, str):
-            container = self.model.unit.get_container(container)
-        self._backend._set_can_connect(container._pebble, val)
-
-    @property
-    def charm(self) -> CharmType:
-        """Return the instance of the charm class that was passed to ``__init__``.
-
-        Note that the Charm is not instantiated until :meth:`.begin()` is called.
-        Until then, attempting to access this property will raise an exception.
-        """
-        if self._charm is None:
-            raise RuntimeError(
-                'The charm instance is not available yet. Call Harness.begin() first.'
-            )
-        return self._charm
-
-    @property
-    def model(self) -> model.Model:
-        """Return the :class:`~ops.model.Model` that is being driven by this Harness."""
-        return self._model
-
-    @property
-    def framework(self) -> framework.Framework:
-        """Return the Framework that is being driven by this Harness."""
-        return self._framework
-
-    def begin(self) -> None:
-        """Instantiate the Charm and start handling events.
-
-        Before calling :meth:`begin`, there is no Charm instance, so changes to the Model won't
-        emit events. Call :meth:`.begin` for :attr:`.charm` to be valid.
-
-        Should only be called once.
-        """
-        if self._charm is not None:
-            raise RuntimeError('cannot call the begin method on the harness more than once')
-
-        # The Framework adds attributes to class objects for events, etc. As such, we can't re-use
-        # the original class against multiple Frameworks. So create a locally defined class
-        # and register it.
-        # TODO: jam 2020-03-16 We are looking to changes this to Instance attributes instead of
-        #       Class attributes which should clean up this ugliness. The API can stay the same
-        class TestEvents(self._charm_cls.on.__class__):
-            pass
-
-        TestEvents.__name__ = self._charm_cls.on.__class__.__name__
-
-        class TestCharm(self._charm_cls):
-            on = TestEvents()
-
-        # Note: jam 2020-03-01 This is so that errors in testing say MyCharm has no attribute foo,
-        # rather than TestCharm has no attribute foo.
-        TestCharm.__name__ = self._charm_cls.__name__
-        self._charm = TestCharm(self._framework)  # type: ignore
-
-    def begin_with_initial_hooks(self) -> None:
-        """Fire the same hooks that Juju would fire at startup.
-
-        This triggers install, relation-created, config-changed, start, pebble-ready (for any
-        containers), and any relation-joined hooks based on what relations have been added before
-        begin was called. Note that all of these are fired before returning control
-        to the test suite, so to introspect what happens at each step, fire them directly
-        (for example, ``Charm.on.install.emit()``).
-
-        To use this with all the normal hooks, instantiate the harness, setup any relations that
-        should be active when the charm starts, and then call this method. This method will
-        automatically create and add peer relations that are specified in metadata.yaml.
-
-        If the charm metadata specifies containers, this sets can_connect to True for all
-        containers (in addition to triggering pebble-ready for each).
-
-        Example::
-
-            harness = Harness(MyCharm)
-            # Do initial setup here
-            # Add storage if needed before begin_with_initial_hooks() is called
-            storage_ids = harness.add_storage('data', count=1)[0]
-            storage_id = storage_id[0] # we only added one storage instance
-            harness.add_relation('db', 'postgresql', unit_data={'key': 'val'})
-            harness.set_leader(True)
-            harness.update_config({'initial': 'config'})
-            harness.begin_with_initial_hooks()
-            # This will cause
-            # install, db-relation-created('postgresql'), leader-elected, config-changed, start
-            # db-relation-joined('postgresql/0'), db-relation-changed('postgresql/0')
-            # To be fired.
-        """
-        self.begin()
-
-        charm = cast(CharmBase, self._charm)
-        # Checking if disks have been added
-        # storage-attached events happen before install
-        for storage_name in self._meta.storages:
-            for storage_index in self._backend.storage_list(storage_name, include_detached=True):
-                s = model.Storage(storage_name, storage_index, self._backend)
-                if self._backend._storage_is_attached(storage_name, storage_index):
-                    # Attaching was done already, but we still need the event to be emitted.
-                    self.charm.on[storage_name].storage_attached.emit(s)
-                else:
-                    self.attach_storage(s.full_id)
-        # Storage done, emit install event
-        charm.on.install.emit()
-
-        # Juju itself iterates what relation to fire based on a map[int]relation, so it doesn't
-        # guarantee a stable ordering between relation events. It *does* give a stable ordering
-        # of joined units for a given relation.
-        items = list(self._meta.relations.items())
-        random.shuffle(items)
-        this_app_name = self._meta.name
-        for relname, rel_meta in items:
-            if rel_meta.role == RelationRole.peer:
-                # If the user has directly added a relation, leave it be, but otherwise ensure
-                # that peer relations are always established at before leader-elected.
-                rel_ids = self._backend._relation_ids_map.get(relname)
-                if rel_ids is None:
-                    self.add_relation(relname, self._meta.name)
-                else:
-                    random.shuffle(rel_ids)
-                    for rel_id in rel_ids:
-                        self._emit_relation_created(relname, rel_id, this_app_name)
-            else:
-                rel_ids = self._backend._relation_ids_map.get(relname, [])
-                random.shuffle(rel_ids)
-                for rel_id in rel_ids:
-                    app_name = self._backend._relation_app_and_units[rel_id]['app']
-                    self._emit_relation_created(relname, rel_id, app_name)
-        if self._backend._is_leader:
-            charm.on.leader_elected.emit()
-        else:
-            charm.on.leader_settings_changed.emit()
-
-        charm.on.config_changed.emit()
-
-        charm.on.start.emit()
-
-        # Set can_connect and fire pebble-ready for any containers.
-        for container_name in self._meta.containers:
-            self.container_pebble_ready(container_name)
-
-        # If the initial hooks do not set a unit status, the Juju controller will switch
-        # the unit status from "Maintenance" to "Unknown". See gh#726
-        post_setup_sts = self._backend.status_get()
-        if post_setup_sts.get('status') == 'maintenance' and not post_setup_sts.get('message'):
-            self._backend._unit_status = {'status': 'unknown', 'message': ''}
-        all_ids = list(self._backend._relation_names.items())
-        random.shuffle(all_ids)
-        for rel_id, rel_name in all_ids:
-            rel_app_and_units = self._backend._relation_app_and_units[rel_id]
-            app_name = rel_app_and_units['app']
-            # Note: Juju *does* fire relation events for a given relation in the sorted order of
-            # the unit names. It also always fires relation-changed immediately after
-            # relation-joined for the same unit.
-            # Juju only fires relation-changed (app) if there is data for the related application
-            relation = self._model.get_relation(rel_name, rel_id)
-            if self._backend._relation_data_raw[rel_id].get(app_name):
-                app = self._model.get_app(app_name)
-                charm.on[rel_name].relation_changed.emit(relation, app, None)
-            for unit_name in sorted(rel_app_and_units['units']):
-                remote_unit = self._model.get_unit(unit_name)
-                charm.on[rel_name].relation_joined.emit(relation, remote_unit.app, remote_unit)
-                charm.on[rel_name].relation_changed.emit(relation, remote_unit.app, remote_unit)
-
-    def cleanup(self) -> None:
-        """Called by the test infrastructure to clean up any temporary directories/files/etc.
-
-        Always call ``self.addCleanup(harness.cleanup)`` after creating a :class:`Harness`.
-        """
-        self._backend._cleanup()
-
-    def _create_meta(
-        self,
-        charm_metadata_yaml: Optional[YAMLStringOrFile],
-        action_metadata_yaml: Optional[YAMLStringOrFile],
-    ) -> CharmMeta:
-        """Create a CharmMeta object.
-
-        Handle the cases where a user doesn't supply explicit metadata snippets.
-        This will try to load metadata from ``<charm_dir>/charmcraft.yaml`` first, then
-        ``<charm_dir>/metadata.yaml`` if charmcraft.yaml does not include metadata,
-        and ``<charm_dir>/actions.yaml`` if charmcraft.yaml does not include actions.
-        """
-        try:
-            filename = inspect.getfile(self._charm_cls)
-        except OSError:
-            charm_dir = None
-        else:
-            charm_dir = pathlib.Path(filename).parents[1]
-
-        charm_metadata: Optional[Dict[str, Any]] = None
-        charmcraft_metadata: Optional[Dict[str, Any]] = None
-        if charm_dir:
-            # Check charmcraft.yaml and load it if it exists
-            charmcraft_meta = charm_dir / 'charmcraft.yaml'
-            if charmcraft_meta.is_file():
-                self._charm_dir = charm_dir
-                charmcraft_metadata = yaml.safe_load(charmcraft_meta.read_text())
-
-        # Load metadata from parameters if provided
-        if charm_metadata_yaml is not None:
-            if isinstance(charm_metadata_yaml, str):
-                charm_metadata_yaml = dedent(charm_metadata_yaml)
-            charm_metadata = yaml.safe_load(charm_metadata_yaml)
-        else:
-            # Check charmcraft.yaml for metadata if no metadata is provided
-            if charmcraft_metadata is not None:
-                meta_keys = ['name', 'summary', 'description']
-                if any(key in charmcraft_metadata for key in meta_keys):
-                    # Unrelated keys in the charmcraft.yaml file will be ignored.
-                    charm_metadata = charmcraft_metadata
-
-            # Still no metadata, check metadata.yaml
-            if charm_dir and charm_metadata is None:
-                metadata_path = charm_dir / 'metadata.yaml'
-                if metadata_path.is_file():
-                    charm_metadata = yaml.safe_load(metadata_path.read_text())
-                    self._charm_dir = charm_dir
-
-        # Use default metadata if metadata is not found
-        if charm_metadata is None:
-            charm_metadata = {'name': 'test-charm'}
-
-        action_metadata: Optional[Dict[str, Any]] = None
-        # Load actions from parameters if provided
-        if action_metadata_yaml is not None:
-            if isinstance(action_metadata_yaml, str):
-                action_metadata_yaml = dedent(action_metadata_yaml)
-            action_metadata = yaml.safe_load(action_metadata_yaml)
-        else:
-            # Check charmcraft.yaml for actions if no actions are provided
-            if charmcraft_metadata is not None and 'actions' in charmcraft_metadata:
-                action_metadata = charmcraft_metadata['actions']
-
-            # Still no actions, check actions.yaml
-            if charm_dir and action_metadata is None:
-                actions_path = charm_dir / 'actions.yaml'
-                if actions_path.is_file():
-                    action_metadata = yaml.safe_load(actions_path.read_text())
-                    self._charm_dir = charm_dir
-
-        return CharmMeta(charm_metadata, action_metadata)
-
-    def _get_config(self, charm_config_yaml: Optional['YAMLStringOrFile']):
-        """If the user passed a config to Harness, use it.
-
-        Otherwise try to load config from ``<charm_dir>/charmcraft.yaml`` first, then
-        ``<charm_dir>/config.yaml`` if charmcraft.yaml does not include config.
-        """
-        try:
-            filename = inspect.getfile(self._charm_cls)
-        except OSError:
-            charm_dir = None
-        else:
-            charm_dir = pathlib.Path(filename).parents[1]
-        config: Optional[Dict[str, Any]] = None
-
-        # Load config from parameters if provided
-        if charm_config_yaml is not None:
-            if isinstance(charm_config_yaml, str):
-                charm_config_yaml = dedent(charm_config_yaml)
-            config = yaml.safe_load(charm_config_yaml)
-        else:
-            if charm_dir:
-                # Check charmcraft.yaml for config if no config is provided
-                charmcraft_meta = charm_dir / 'charmcraft.yaml'
-                if charmcraft_meta.is_file():
-                    charmcraft_metadata: Dict[str, Any] = yaml.safe_load(
-                        charmcraft_meta.read_text()
-                    )
-                    config = charmcraft_metadata.get('config')
-
-                # Still no config, check config.yaml
-                if config is None:
-                    config_path = charm_dir / 'config.yaml'
-                    if config_path.is_file():
-                        config = yaml.safe_load(config_path.read_text())
-                        self._charm_dir = charm_dir
-
-            # Use default config if config is not found
-            if config is None:
-                config = {}
-
-        if not isinstance(config, dict):
-            raise TypeError(config)
-        return cast('_RawConfig', config)
-
-    def add_oci_resource(
-        self, resource_name: str, contents: Optional[Mapping[str, str]] = None
-    ) -> None:
-        """Add OCI resources to the backend.
-
-        This will register an OCI resource and create a temporary file for processing metadata
-        about the resource. A default set of values will be used for all the file contents
-        unless a specific contents dict is provided.
-
-        Args:
-            resource_name: Name of the resource to add custom contents to.
-            contents: Optional custom dict to write for the named resource.
-        """
-        if not contents:
-            contents = {
-                'registrypath': 'registrypath',
-                'username': 'username',
-                'password': 'password',
-            }
-        if resource_name not in self._meta.resources:
-            raise RuntimeError(f'Resource {resource_name} is not a defined resources')
-        if self._meta.resources[resource_name].type != 'oci-image':
-            raise RuntimeError(f'Resource {resource_name} is not an OCI Image')
-
-        as_yaml = yaml.safe_dump(contents)
-        self._backend._resources_map[resource_name] = ('contents.yaml', as_yaml)
-
-    def add_resource(self, resource_name: str, content: AnyStr) -> None:
-        """Add content for a resource to the backend.
-
-        This will register the content, so that a call to ``model.resources.fetch(resource_name)``
-        will return a path to a file containing that content.
-
-        Args:
-            resource_name: The name of the resource being added
-            content: Either string or bytes content, which will be the content of the filename
-                returned by resource-get. If contents is a string, it will be encoded in utf-8
-        """
-        if resource_name not in self._meta.resources:
-            raise RuntimeError(f'Resource {resource_name} is not a defined resource')
-        record = self._meta.resources[resource_name]
-        if record.type != 'file':
-            raise RuntimeError(
-                f'Resource {resource_name} is not a file, but actually {record.type}'
-            )
-        filename = record.filename
-        if filename is None:
-            filename = resource_name
-
-        self._backend._resources_map[resource_name] = (filename, content)
-
-    def populate_oci_resources(self) -> None:
-        """Populate all OCI resources."""
-        for name, data in self._meta.resources.items():
-            if data.type == 'oci-image':
-                self.add_oci_resource(name)
-
-    def disable_hooks(self) -> None:
-        """Stop emitting hook events when the model changes.
-
-        This can be used by developers to stop changes to the model from emitting events that
-        the charm will react to. Call :meth:`.enable_hooks`
-        to re-enable them.
-        """
-        self._hooks_enabled = False
-
-    def enable_hooks(self) -> None:
-        """Re-enable hook events from charm.on when the model is changed.
-
-        By default, hook events are enabled once :meth:`.begin` is called,
-        but if :meth:`.disable_hooks` is used, this method will enable
-        them again.
-        """
-        self._hooks_enabled = True
-
-    @contextmanager
-    def hooks_disabled(self):
-        """A context manager to run code with hooks disabled.
-
-        Example::
-
-            with harness.hooks_disabled():
-                # things in here don't fire events
-                harness.set_leader(True)
-                harness.update_config(unset=['foo', 'bar'])
-            # things here will again fire events
-        """
-        if self._hooks_enabled:
-            self.disable_hooks()
-            try:
-                yield None
-            finally:
-                self.enable_hooks()
-        else:
-            yield None
-
-    def _next_relation_id(self):
-        rel_id = self._relation_id_counter
-        self._relation_id_counter += 1
-        return rel_id
-
-    def add_storage(self, storage_name: str, count: int = 1, *, attach: bool = False) -> List[str]:
-        """Create a new storage device and attach it to this unit.
-
-        To have repeatable tests, each device will be initialized with
-        location set to /[tmpdir]/<storage_name>N, where N is the counter and
-        will be a number from [0,total_num_disks-1].
-
-        The test harness uses symbolic links to imitate storage mounts, which may lead to some
-        inconsistencies compared to the actual charm.
-
-        Args:
-            storage_name: The storage backend name on the Charm
-            count: Number of disks being added
-            attach: True to also attach the storage mount; if :meth:`begin`
-                has been called a True value will also emit storage-attached
-
-        Return:
-            A list of storage IDs, e.g. ["my-storage/1", "my-storage/2"].
-        """
-        if storage_name not in self._meta.storages:
-            raise RuntimeError(
-                f"the key '{storage_name}' is not specified as a storage key in metadata"
-            )
-
-        storage_indices = self._backend.storage_add(storage_name, count)
-
-        ids: List[str] = []
-        for storage_index in storage_indices:
-            s = model.Storage(storage_name, storage_index, self._backend)
-            ids.append(s.full_id)
-            if attach:
-                self.attach_storage(s.full_id)
-        return ids
-
-    def detach_storage(self, storage_id: str) -> None:
-        """Detach a storage device.
-
-        The intent of this function is to simulate a ``juju detach-storage`` call.
-        It will trigger a storage-detaching hook if the storage unit in question exists
-        and is presently marked as attached.
-
-        Note that the Charm is not instantiated until :meth:`begin` is called.
-        Until then, attempting to use this method will raise an exception.
-
-        Args:
-            storage_id: The full storage ID of the storage unit being detached, including the
-                storage key, e.g. my-storage/0.
-        """
-        if self._charm is None:
-            raise RuntimeError('cannot detach storage before Harness is initialised')
-        storage_name, storage_index = storage_id.split('/', 1)
-        storage_index = int(storage_index)
-        storage_attached = self._backend._storage_is_attached(storage_name, storage_index)
-        if storage_attached and self._hooks_enabled:
-            self.charm.on[storage_name].storage_detaching.emit(
-                model.Storage(storage_name, storage_index, self._backend)
-            )
-        self._backend._storage_detach(storage_id)
-
-    def attach_storage(self, storage_id: str) -> None:
-        """Attach a storage device.
-
-        The intent of this function is to simulate a ``juju attach-storage`` call.
-        If called after :meth:`begin` and hooks are not disabled, it will trigger
-        a storage-attached hook if the storage unit in question exists
-        and is presently marked as detached.
-
-        The test harness uses symbolic links to imitate storage mounts, which may lead to some
-        inconsistencies compared to the actual charm.
-
-        Args:
-            storage_id: The full storage ID of the storage unit being attached, including the
-                storage key, e.g. my-storage/0.
-        """
-        if not self._backend._storage_attach(storage_id):
-            return  # storage was already attached
-        if not self._charm or not self._hooks_enabled:
-            return  # don't need to run hook callback
-
-        storage_name, storage_index = storage_id.split('/', 1)
-
-        # Reset associated cached value in the storage mappings.  If we don't do this,
-        # Model._storages won't return Storage objects for subsequently-added storage.
-        self._model._storages._invalidate(storage_name)
-
-        storage_index = int(storage_index)
-        self.charm.on[storage_name].storage_attached.emit(
-            model.Storage(storage_name, storage_index, self._backend)
-        )
-
-    def remove_storage(self, storage_id: str) -> None:
-        """Detach a storage device.
-
-        The intent of this function is to simulate a ``juju remove-storage`` call.
-        It will trigger a storage-detaching hook if the storage unit in question exists
-        and is presently marked as attached.  Then it will remove the storage
-        unit from the testing backend.
-
-        Args:
-            storage_id: The full storage ID of the storage unit being removed, including the
-                storage key, e.g. my-storage/0.
-
-        Raises:
-            RuntimeError: if the storage is not in the metadata.
-        """
-        storage_name, storage_index = storage_id.split('/', 1)
-        storage_index = int(storage_index)
-        if storage_name not in self._meta.storages:
-            raise RuntimeError(
-                f"the key '{storage_name}' is not specified as a storage key in metadata"
-            )
-        is_attached = self._backend._storage_is_attached(storage_name, storage_index)
-        if self._charm is not None and self._hooks_enabled and is_attached:
-            self.charm.on[storage_name].storage_detaching.emit(
-                model.Storage(storage_name, storage_index, self._backend)
-            )
-        self._backend._storage_remove(storage_id)
-
-    def add_relation(
-        self,
-        relation_name: str,
-        remote_app: str,
-        *,
-        app_data: Optional[Mapping[str, str]] = None,
-        unit_data: Optional[Mapping[str, str]] = None,
-    ) -> int:
-        """Declare that there is a new relation between this application and `remote_app`.
-
-        This function creates a relation with an application and triggers a
-        :class:`RelationCreatedEvent <ops.RelationCreatedEvent>`.
-        To match Juju's behaviour, it also creates a default network binding on this endpoint.
-        If you want to associate a custom network to this binding (or a global default network),
-        provide one using :meth:`add_network` before calling this function.
-
-        If `app_data` or `unit_data` are provided, also add a new unit
-        (``<remote_app>/0``) to the relation and trigger
-        :class:`RelationJoinedEvent <ops.RelationJoinedEvent>`. Then update
-        the application data if `app_data` is provided and the unit data if
-        `unit_data` is provided, triggering
-        :class:`RelationChangedEvent <ops.RelationChangedEvent>` after each update.
-        Alternatively, charm tests can call :meth:`add_relation_unit` and
-        :meth:`update_relation_data` explicitly.
-
-        For peer relations defined in the charm's metadata, :meth:`begin_with_initial_hooks`
-        will create them automatically, so the caller doesn't need to call :meth:`add_relation`.
-        If the caller chooses to add a peer relation by themselves, make sure to call
-        :meth:`add_relation` before :meth:`begin_with_initial_hooks` so that Harness won't
-        create it again.
-
-        Example usage::
-
-            secret_id = harness.add_model_secret('mysql', {'password': 'SECRET'})
-            harness.add_relation('db', 'mysql', unit_data={
-                'host': 'mysql.localhost,
-                'username': 'appuser',
-                'secret-id': secret_id,
-            })
-
-        Args:
-            relation_name: The relation on the charm that is being integrated with.
-            remote_app: The name of the application that is being integrated with.
-                To add a peer relation, set to the name of *this* application.
-            app_data: If provided, also add a new unit to the relation
-                (triggering relation-joined) and set the *application* relation data
-                (triggering relation-changed).
-            unit_data: If provided, also add a new unit to the relation
-                (triggering relation-joined) and set the *unit* relation data
-                (triggering relation-changed).
-
-        Return:
-            The ID of the relation created.
-        """
-        if not (
-            relation_name in self._meta.provides
-            or relation_name in self._meta.requires
-            or relation_name in self._meta.peers
-        ):
-            raise RelationNotFoundError(f'relation {relation_name!r} not declared in metadata')
-
-        relation_id = self._next_relation_id()
-        self._backend._relation_ids_map.setdefault(relation_name, []).append(relation_id)
-        self._backend._relation_names[relation_id] = relation_name
-        self._backend._relation_list_map[relation_id] = []
-        self._backend._relation_data_raw[relation_id] = {
-            remote_app: {},
-            self._backend.unit_name: {},
-            self._backend.app_name: {},
-        }
-
-        self._backend._relation_app_and_units[relation_id] = {
-            'app': remote_app,
-            'units': [],
-        }
-        # Reload the relation_ids list
-        if self._model is not None:
-            self._model.relations._invalidate(relation_name)
-        self._emit_relation_created(relation_name, relation_id, remote_app)
-
-        if app_data is not None or unit_data is not None:
-            remote_unit = remote_app + '/0'
-            self.add_relation_unit(relation_id, remote_unit)
-            if app_data is not None:
-                self.update_relation_data(relation_id, remote_app, app_data)
-            if unit_data is not None:
-                self.update_relation_data(relation_id, remote_unit, unit_data)
-
-        # If we have a default network binding configured, respect it.
-        if not self._backend._networks.get((None, None)):
-            # If we don't already have a network binding for this relation id, create one.
-            if not self._backend._networks.get((relation_name, relation_id)):
-                self.add_network('10.0.0.10', endpoint=relation_name, relation_id=relation_id)
-            # If we don't already have a default network binding for this endpoint, create one.
-            if not self._backend._networks.get((relation_name, None)):
-                self.add_network('192.0.2.0', endpoint=relation_name)
-
-        return relation_id
-
-    def remove_relation(self, relation_id: int) -> None:
-        """Remove a relation.
-
-        Args:
-            relation_id: The relation ID for the relation to be removed.
-
-        Raises:
-            RelationNotFoundError: if relation id is not valid
-        """
-        rel_names = self._backend._relation_names
-        try:
-            relation_name = rel_names[relation_id]
-            remote_app = self._backend.relation_remote_app_name(relation_id)
-        except KeyError as e:
-            raise model.RelationNotFoundError from e
-
-        rel_list_map = self._backend._relation_list_map
-        for unit_name in rel_list_map[relation_id].copy():
-            self.remove_relation_unit(relation_id, unit_name)
-
-        prev_broken_id = None  # Silence linter warning.
-        if self._model is not None:
-            # Let the model's RelationMapping know that this relation is broken.
-            # Normally, this is handled in `main`, but while testing we create
-            # the `Model` object and keep it around for multiple events.
-            prev_broken_id = self._model._relations._broken_relation_id
-            self._model.relations._broken_relation_id = relation_id
-            # Ensure that we don't offer a cached relation.
-            self._model.relations._invalidate(relation_name)
-        self._emit_relation_broken(relation_name, relation_id, remote_app)
-        if self._model is not None:
-            self._model.relations._broken_relation_id = prev_broken_id
-            self._model.relations._invalidate(relation_name)
-
-        self._backend._relation_app_and_units.pop(relation_id)
-        self._backend._relation_data_raw.pop(relation_id)
-        rel_list_map.pop(relation_id)
-        ids_map = self._backend._relation_ids_map
-        ids_map[relation_name].remove(relation_id)
-        rel_names.pop(relation_id)
-
-        # Remove secret grants that give access via this relation
-        for secret in self._backend._secrets:
-            secret.grants = {
-                rid: names for rid, names in secret.grants.items() if rid != relation_id
-            }
-
-    def _emit_relation_created(
-        self, relation_name: str, relation_id: int, remote_app: str
-    ) -> None:
-        """Trigger relation-created for a given relation with a given remote application."""
-        if self._charm is None or not self._hooks_enabled:
-            return
-        relation = self._model.get_relation(relation_name, relation_id)
-        app = self._model.get_app(remote_app)
-        self._charm.on[relation_name].relation_created.emit(relation, app)
-
-    def _emit_relation_broken(self, relation_name: str, relation_id: int, remote_app: str) -> None:
-        """Trigger relation-broken for a given relation with a given remote application."""
-        if self._charm is None or not self._hooks_enabled:
-            return
-        relation = self._model.get_relation(relation_name, relation_id)
-        app = self._model.get_app(remote_app)
-        self._charm.on[relation_name].relation_broken.emit(relation, app)
-
-    def add_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
-        """Add a new unit to a relation.
-
-        This will trigger a `relation_joined` event. This would naturally be
-        followed by a `relation_changed` event, which can be triggered with
-        :meth:`.update_relation_data`. This separation is artificial in the
-        sense that Juju will always fire the two, but is intended to make
-        testing relations and their data bags slightly more natural.
-
-        Unless finer-grained control is needed, most charm tests can call
-        :meth:`add_relation` with the `app_data` or `unit_data` argument
-        instead of using this function.
-
-        Example::
-
-            rel_id = harness.add_relation('db', 'postgresql')
-            harness.add_relation_unit(rel_id, 'postgresql/0')
-
-        Args:
-            relation_id: The integer relation identifier (as returned by :meth:`add_relation`).
-            remote_unit_name: A string representing the remote unit that is being added.
-        """
-        self._backend._relation_list_map[relation_id].append(remote_unit_name)
-        # we can write remote unit data iff we are not in a hook env
-        relation_name = self._backend._relation_names[relation_id]
-        relation = self._model.get_relation(relation_name, relation_id)
-
-        if not relation:
-            raise RuntimeError(
-                'Relation id {} is mapped to relation name {},'
-                'but no relation matching that name was found.'
-            )
-
-        self._backend._relation_data_raw[relation_id][remote_unit_name] = {}
-        app = relation.app
-        if not remote_unit_name.startswith(app.name):
-            warnings.warn(
-                f'Remote unit name invalid: '
-                f'the remote application of {relation_name} is called {app.name!r}; '
-                f'the remote unit name should be {app.name}/<some-number>, '
-                f'not {remote_unit_name!r}.'
-            )
-        app_and_units = self._backend._relation_app_and_units
-        app_and_units[relation_id]['units'].append(remote_unit_name)
-        # Make sure that the Model reloads the relation_list for this relation_id, as well as
-        # reloading the relation data for this unit.
-        remote_unit = self._model.get_unit(remote_unit_name)
-        unit_cache = relation.data.get(remote_unit, None)
-        if unit_cache is not None:
-            unit_cache._invalidate()
-        self._model.relations._invalidate(relation_name)
-        if self._charm is None or not self._hooks_enabled:
-            return
-        self._charm.on[relation_name].relation_joined.emit(relation, remote_unit.app, remote_unit)
-
-    def remove_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
-        """Remove a unit from a relation.
-
-        Example::
-
-            rel_id = harness.add_relation('db', 'postgresql')
-            harness.add_relation_unit(rel_id, 'postgresql/0')
-            ...
-            harness.remove_relation_unit(rel_id, 'postgresql/0')
-
-        This will trigger a `relation_departed` event. This would
-        normally be followed by a `relation_changed` event triggered
-        by Juju. However, when using the test harness, a
-        `relation_changed` event must be triggered using
-        :meth:`.update_relation_data`. This deviation from normal Juju
-        behaviour facilitates testing by making each step in the
-        charm life cycle explicit.
-
-        Args:
-            relation_id: The integer relation identifier (as returned by :meth:`add_relation`).
-            remote_unit_name: A string representing the remote unit that is being removed.
-        """
-        relation_name = self._backend._relation_names[relation_id]
-
-        # gather data to invalidate cache later
-        remote_unit = self._model.get_unit(remote_unit_name)
-        relation = self._model.get_relation(relation_name, relation_id)
-
-        if not relation:
-            # This should not really happen, since there being a relation name mapped
-            # to this ID in _relation_names should guarantee that you created the relation
-            # following the proper path, but still...
-            raise RuntimeError(
-                'Relation id {} is mapped to relation name {},'
-                'but no relation matching that name was found.'
-            )
-
-        unit_cache = relation.data.get(remote_unit, None)
-
-        # remove the unit from the list of units in the relation
-        relation.units.remove(remote_unit)
-
-        self._emit_relation_departed(relation_id, remote_unit_name)
-        # remove the relation data for the departed unit now that the event has happened
-        self._backend._relation_list_map[relation_id].remove(remote_unit_name)
-        self._backend._relation_app_and_units[relation_id]['units'].remove(remote_unit_name)
-        self._backend._relation_data_raw[relation_id].pop(remote_unit_name)
-        self.model._relations._invalidate(relation_name=relation.name)
-
-        if unit_cache is not None:
-            unit_cache._invalidate()
-
-    def _emit_relation_departed(self, relation_id: int, unit_name: str):
-        """Trigger relation-departed event for a given relation id and unit."""
-        if self._charm is None or not self._hooks_enabled:
-            return
-        rel_name = self._backend._relation_names[relation_id]
-        relation = self.model.get_relation(rel_name, relation_id)
-        if '/' in unit_name:
-            app_name = unit_name.split('/')[0]
-            app = self.model.get_app(app_name)
-            unit = self.model.get_unit(unit_name)
-        else:
-            raise ValueError('Invalid Unit Name')
-        self._charm.on[rel_name].relation_departed.emit(relation, app, unit, unit_name)
-
-    def get_relation_data(self, relation_id: int, app_or_unit: AppUnitOrName) -> Mapping[str, str]:
-        """Get the relation data bucket for a single app or unit in a given relation.
-
-        This ignores all of the safety checks of who can and can't see data in relations (eg,
-        non-leaders can't read their own application's relation data because there are no events
-        that keep that data up-to-date for the unit).
-
-        Args:
-            relation_id: The relation whose content we want to look at.
-            app_or_unit: An :class:`Application <ops.Application>` or
-                :class:`Unit <ops.Unit>` instance, or its name, whose data we
-                want to read.
-
-        Return:
-            A dict containing the relation data for ``app_or_unit`` or None.
-
-        Raises:
-            KeyError: if ``relation_id`` doesn't exist
-        """
-        name = _get_app_or_unit_name(app_or_unit)
-
-        # bypass access control by going directly to raw
-        return self._backend._relation_data_raw[relation_id].get(name, None)
-
-    def get_pod_spec(self) -> Tuple[Mapping[Any, Any], Mapping[Any, Any]]:
-        """Return the content of the pod spec as last set by the charm.
-
-        This returns both the pod spec and any k8s_resources that were supplied.
-        See the signature of :meth:`Pod.set_spec <ops.Pod.set_spec>`.
-        """
-        return self._backend._pod_spec
-
-    def get_container_pebble_plan(self, container_name: str) -> pebble.Plan:
-        """Return the current plan that Pebble is executing for the given container.
-
-        Args:
-            container_name: The simple name of the associated container
-
-        Return:
-            The Pebble plan for this container. Use
-            :meth:`Plan.to_yaml <ops.pebble.Plan.to_yaml>` to get a string
-            form for the content.
-
-        Raises:
-            KeyError: if no Pebble client exists for that container name (should only happen if
-                container is not present in ``metadata.yaml``).
-        """
-        client = self._backend._pebble_clients.get(container_name)
-        if client is None:
-            raise KeyError(f'no known pebble client for container "{container_name}"')
-        return client.get_plan()
-
-    def container_pebble_ready(self, container_name: str):
-        """Fire the pebble_ready hook for the associated container.
-
-        This will switch the given container's ``can_connect`` state to True
-        before the hook function is called.
-
-        It will do nothing if :meth:`begin()` has not been called.
-        """
-        if self._charm is None:
-            return
-        container = self.model.unit.get_container(container_name)
-        self.set_can_connect(container, True)
-        self.charm.on[container_name].pebble_ready.emit(container)
-
-    def pebble_notify(
-        self,
-        container_name: str,
-        key: str,
-        *,
-        data: Optional[Dict[str, str]] = None,
-        repeat_after: Optional[datetime.timedelta] = None,
-        type: pebble.NoticeType = pebble.NoticeType.CUSTOM,
-    ) -> str:
-        """Record a Pebble notice with the specified key and data.
-
-        If :meth:`begin` has been called and the notice is new or was repeated,
-        this will trigger a notice event of the appropriate type, for example
-        :class:`ops.PebbleCustomNoticeEvent`.
-
-        Args:
-            container_name: Name of workload container.
-            key: Notice key; must be in "example.com/path" format.
-            data: Data fields for this notice.
-            repeat_after: Only allow this notice to repeat after this duration
-                has elapsed (the default is to always repeat).
-            type: Notice type (currently only "custom" notices are supported).
-
-        Returns:
-            The notice's ID.
-        """
-        container = self.model.unit.get_container(container_name)
-        client = self._backend._pebble_clients[container.name]
-
-        id, new_or_repeated = client._notify(type, key, data=data, repeat_after=repeat_after)
-
-        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 and data:
-                kind = pebble.ChangeKind(data.get('kind'))
-                status = pebble.ChangeStatus(client.get_change(key).status)
-                if kind == pebble.ChangeKind.PERFORM_CHECK and status == pebble.ChangeStatus.ERROR:
-                    self.charm.on[container_name].pebble_check_failed.emit(
-                        container, data['check-name']
-                    )
-                elif (
-                    kind == pebble.ChangeKind.RECOVER_CHECK and status == pebble.ChangeStatus.DONE
-                ):
-                    self.charm.on[container_name].pebble_check_recovered.emit(
-                        container, data['check-name']
-                    )
-
-        return id
-
-    def get_workload_version(self) -> str:
-        """Read the workload version that was set by the unit."""
-        return self._backend._workload_version
-
-    def set_model_info(self, name: Optional[str] = None, uuid: Optional[str] = None) -> None:
-        """Set the name and UUID of the model that this is representing.
-
-        Cannot be called once :meth:`begin` has been called. Use it to set the
-        value that will be returned by :attr:`Model.name <ops.Model.name>` and
-        :attr:`Model.uuid <ops.Model.uuid>`.
-
-        This is a convenience method to invoke both :meth:`set_model_name`
-        and :meth:`set_model_uuid` at once.
-        """
-        if name is not None:
-            self.set_model_name(name)
-        if uuid is not None:
-            self.set_model_uuid(uuid)
-
-    def set_model_name(self, name: str) -> None:
-        """Set the name of the Model that this is representing.
-
-        Cannot be called once :meth:`begin` has been called. Use it to set the
-        value that will be returned by :attr:`Model.name <ops.Model.name>`.
-        """
-        if self._charm is not None:
-            raise RuntimeError('cannot set the Model name after begin()')
-        self._backend.model_name = name
-
-    def set_model_uuid(self, uuid: str) -> None:
-        """Set the uuid of the Model that this is representing.
-
-        Cannot be called once :meth:`begin` has been called. Use it to set the
-        value that will be returned by :attr:`Model.uuid <ops.Model.uuid>`.
-        """
-        if self._charm is not None:
-            raise RuntimeError('cannot set the Model uuid after begin()')
-        self._backend.model_uuid = uuid
-
-    def update_relation_data(
-        self,
-        relation_id: int,
-        app_or_unit: str,
-        key_values: Mapping[str, str],
-    ) -> None:
-        """Update the relation data for a given unit or application in a given relation.
-
-        This also triggers the `relation_changed` event for the given ``relation_id``.
-
-        Unless finer-grained control is needed, most charm tests can call
-        :meth:`add_relation` with the `app_data` or `unit_data` argument
-        instead of using this function.
-
-        Args:
-            relation_id: The integer relation ID representing this relation.
-            app_or_unit: The unit or application name that is being updated.
-                This can be the local or remote application.
-            key_values: Each key/value will be updated in the relation data.
-        """
-        relation_name = self._backend._relation_names[relation_id]
-        relation = self._model.get_relation(relation_name, relation_id)
-        if '/' in app_or_unit:
-            entity = self._model.get_unit(app_or_unit)
-        else:
-            entity = self._model.get_app(app_or_unit)
-
-        if not relation:
-            raise RuntimeError(
-                'Relation id {} is mapped to relation name {},'
-                'but no relation matching that name was found.'
-            )
-
-        rel_data = relation.data.get(entity, None)
-        if rel_data is not None:
-            # rel_data may have cached now-stale data, so _invalidate() it.
-            # Note, this won't cause the data to be loaded if it wasn't already.
-            rel_data._invalidate()
-
-        old_values = self._backend._relation_data_raw[relation_id][app_or_unit].copy()
-        assert isinstance(old_values, dict), old_values
-
-        # get a new relation instance to ensure a clean state
-        new_relation_instance = self.model.relations._get_unique(relation.name, relation_id)
-        assert new_relation_instance is not None  # type guard; this passed before...
-        databag = new_relation_instance.data[entity]
-        # ensure that WE as harness can temporarily write the databag
-        with self._event_context(''):
-            values_have_changed = False
-            for k, v in key_values.items():
-                if v == '':
-                    if databag.pop(k, None) != v:
-                        values_have_changed = True
-                else:
-                    if k not in databag or databag[k] != v:
-                        databag[k] = v  # this triggers relation-set
-                        values_have_changed = True
-
-        if not values_have_changed:
-            # Do not issue a relation changed event if the data bags have not changed
-            return
-
-        if app_or_unit == self._model.unit.name:
-            # No events for our own unit
-            return
-        if app_or_unit == self._model.app.name:
-            # updating our own app only generates an event if it is a peer relation and we
-            # aren't the leader
-            is_peer = self._meta.relations[relation_name].role.is_peer()
-            if not is_peer:
-                return
-            if self._model.unit.is_leader():
-                return
-        self._emit_relation_changed(relation_id, app_or_unit)
-
-    def _emit_relation_changed(self, relation_id: int, app_or_unit: str):
-        if self._charm is None or not self._hooks_enabled:
-            return
-        rel_name = self._backend._relation_names[relation_id]
-        relation = self.model.get_relation(rel_name, relation_id)
-        if '/' in app_or_unit:
-            app_name = app_or_unit.split('/')[0]
-            unit_name = app_or_unit
-            app = self.model.get_app(app_name)
-            unit = self.model.get_unit(unit_name)
-            args = (relation, app, unit)
-        else:
-            app_name = app_or_unit
-            app = self.model.get_app(app_name)
-            args = (relation, app)
-        self._charm.on[rel_name].relation_changed.emit(*args)
-
-    def _update_config(
-        self,
-        key_values: Optional[Mapping[str, Union[str, int, float, bool]]] = None,
-        unset: Iterable[str] = (),
-    ) -> None:
-        """Update the config as seen by the charm.
-
-        This will *not* trigger a `config_changed` event, and is intended for internal use.
-
-        Note that the `key_values` mapping will only add or update configuration items.
-        To remove existing ones, see the `unset` parameter.
-
-        Args:
-            key_values: A Mapping of key:value pairs to update in config.
-            unset: An iterable of keys to remove from config.
-        """
-        # NOTE: jam 2020-03-01 Note that this sort of works "by accident". Config
-        # is a LazyMapping, but its _load returns a dict and this method mutates
-        # the dict that Config is caching. Arguably we should be doing some sort
-        # of charm.framework.model.config._invalidate()
-        config = self._backend._config
-        if key_values is not None:
-            for key, value in key_values.items():
-                if key in config._defaults:
-                    if value is not None:
-                        config._config_set(key, value)
-                else:
-                    raise ValueError(f"unknown config option: '{key}'")
-
-        for key in unset:
-            # When the key is unset, revert to the default if one exists
-            default = config._defaults.get(key, None)
-            if default is not None:
-                config._config_set(key, default)
-            else:
-                config.pop(key, None)
-
-    def update_config(
-        self,
-        key_values: Optional[Mapping[str, Union[str, int, float, bool]]] = None,
-        unset: Iterable[str] = (),
-    ) -> None:
-        """Update the config as seen by the charm.
-
-        This will trigger a `config_changed` event.
-
-        Note that the ``key_values`` mapping will only add or update configuration items.
-        To remove existing ones, see the ``unset`` parameter.
-
-        Args:
-            key_values: A Mapping of key:value pairs to update in config.
-            unset: An iterable of keys to remove from config.
-                This sets the value to the default if defined,
-                otherwise removes the key altogether.
-
-        Raises:
-            ValueError: if the key is not present in the config.
-        """
-        self._update_config(key_values, unset)
-        if self._charm is None or not self._hooks_enabled:
-            return
-        self._charm.on.config_changed.emit()
-
-    def set_leader(self, is_leader: bool = True) -> None:
-        """Set whether this unit is the leader or not.
-
-        If this charm becomes a leader then `leader_elected` will be triggered.  If :meth:`begin`
-        has already been called, then the charm's peer relation should usually be added *prior* to
-        calling this method (with :meth:`add_relation`) to properly initialise and make
-        available relation data that leader elected hooks may want to access.
-
-        Args:
-            is_leader: Whether this unit is the leader.
-        """
-        self._backend._is_leader = is_leader
-
-        # Note: jam 2020-03-01 currently is_leader is cached at the ModelBackend level, not in
-        # the Model objects, so this automatically gets noticed.
-        if is_leader and self._charm is not None and self._hooks_enabled:
-            self._charm.on.leader_elected.emit()
-
-    def set_planned_units(self, num_units: int) -> None:
-        """Set the number of "planned" units.
-
-        This is the value that :meth:`Application.planned_units <ops.Application.planned_units>`
-        should return.
-
-        In real world circumstances, this number will be the number of units
-        in the application. That is, this number will be the number of peers
-        this unit has, plus one, as we count our own unit in the total.
-
-        A change to the return from ``planned_units`` will not generate an
-        event. Typically, a charm author would check planned units during a
-        config or install hook, or after receiving a peer relation joined
-        event.
-        """
-        if num_units < 0:
-            raise TypeError('num_units must be 0 or a positive integer.')
-        self._backend._planned_units = num_units
-
-    def reset_planned_units(self) -> None:
-        """Reset the planned units override.
-
-        This allows the harness to fall through to the built in methods that will try to
-        guess at a value for planned units, based on the number of peer relations that
-        have been setup in the testing harness.
-        """
-        self._backend._planned_units = None
-
-    def add_network(
-        self,
-        address: str,
-        *,
-        endpoint: Optional[str] = None,
-        relation_id: Optional[int] = None,
-        cidr: Optional[str] = None,
-        interface: str = 'eth0',
-        ingress_addresses: Optional[Iterable[str]] = None,
-        egress_subnets: Optional[Iterable[str]] = None,
-    ):
-        """Add simulated network data for the given relation endpoint (binding).
-
-        Calling this multiple times with the same (binding, relation_id)
-        combination will replace the associated network data.
-
-        Example::
-
-            # Set network info for default binding
-            harness.add_network('10.0.0.10')
-
-            # Or set network info for specific endpoint
-            harness.add_network('10.0.0.10', endpoint='db')
-
-        After either of those calls, the following will be true (in the first
-        case, the simulated network-get will fall back to the default binding)::
-
-            binding = harness.model.get_binding('db')
-            assert binding.network.bind_address == ipaddress.IPv4Address('10.0.0.10'))
-
-        Args:
-            address: Binding's IPv4 or IPv6 address.
-            endpoint: Name of relation endpoint (binding) to add network
-                data for. If not provided, add info for the default binding.
-            relation_id: Relation ID for the binding. If provided, the
-                endpoint argument must be provided and correspond. If not
-                provided, add network data for the endpoint's default binding.
-            cidr: Binding's CIDR. Defaults to "<address>/24" if address is an
-                IPv4 address, or "<address>/64" if address is IPv6 (the host
-                bits are cleared).
-            interface: Name of network interface.
-            ingress_addresses: List of ingress addresses. Defaults to [address].
-            egress_subnets: List of egress subnets. Defaults to [cidr].
-
-        Raises:
-            ModelError: If the endpoint is not a known relation name, or the
-                relation_id is incorrect or doesn't match the endpoint.
-            ValueError: If address is not an IPv4 or IPv6 address.
-        """
-        if endpoint is not None and endpoint not in self._meta.relations:
-            raise model.ModelError(f'{endpoint!r} is not a known endpoint')
-        if relation_id is not None:
-            if endpoint is None:
-                raise TypeError('endpoint must be set if relation_id is provided')
-            relation_name = self._backend._relation_names.get(relation_id)
-            if relation_name is None:
-                raise model.ModelError(
-                    f'relation_id {relation_id} has not been added; use add_relation'
-                )
-            if endpoint != relation_name:
-                raise model.ModelError(
-                    f'endpoint {endpoint!r} does not correspond to relation_id '
-                    f'{relation_id} ({relation_name!r})'
-                )
-
-        parsed_address = ipaddress.ip_address(address)  # raises ValueError if not an IP
-        if cidr is None:
-            if isinstance(parsed_address, ipaddress.IPv4Address):
-                cidr = str(ipaddress.IPv4Network(address + '/24', strict=False))
-            else:
-                cidr = str(ipaddress.IPv6Network(address + '/64', strict=False))
-        if ingress_addresses is None:
-            ingress_addresses = [address]
-        if egress_subnets is None:
-            egress_subnets = [cidr]
-
-        data = {
-            'bind-addresses': [
-                {
-                    'interface-name': interface,
-                    'addresses': [
-                        {'cidr': cidr, 'value': address},
-                    ],
-                }
-            ],
-            'egress-subnets': list(egress_subnets),
-            'ingress-addresses': list(ingress_addresses),
-        }
-        self._backend._networks[endpoint, relation_id] = data
-
-    def _get_backend_calls(self, reset: bool = True) -> List[Tuple[Any, ...]]:
-        """Return the calls that we have made to the TestingModelBackend.
-
-        This is useful mostly for testing the framework itself, so that we can assert that we
-        do/don't trigger extra calls.
-
-        Args:
-            reset: If True, reset the calls list back to empty, if false, the call list is
-                preserved.
-
-        Return:
-            ``[(call1, args...), (call2, args...)]``
-        """
-        calls = self._backend._calls.copy()
-        if reset:
-            self._backend._calls.clear()
-        return calls
-
-    def add_model_secret(self, owner: AppUnitOrName, content: Dict[str, str]) -> str:
-        """Add a secret owned by the remote application or unit specified.
-
-        This is named :code:`add_model_secret` instead of :code:`add_secret`
-        to avoid confusion with the :meth:`ops.Application.add_secret`
-        and :meth:`ops.Unit.add_secret` methods used by secret owner
-        charms.
-
-        Args:
-            owner: The name of the remote application (or specific remote
-                unit) that will own the secret.
-            content: A key-value mapping containing the payload of the secret,
-                for example :code:`{"password": "foo123"}`.
-
-        Return:
-            The ID of the newly-secret added.
-        """
-        owner_name = _get_app_or_unit_name(owner)
-        model.Secret._validate_content(content)
-        return self._backend._secret_add(content, owner_name)
-
-    def add_user_secret(self, content: Dict[str, str]) -> str:
-        """Add a secret owned by the user, simulating the ``juju add-secret`` command.
-
-        Args:
-            content: A key-value mapping containing the payload of the secret,
-                for example :code:`{"password": "foo123"}`.
-
-        Return:
-            The ID of the newly-added secret.
-
-        Example usage (the parameter ``harness`` in the test function is
-        a pytest fixture that does setup/teardown, see :class:`Harness`)::
-
-            # charmcraft.yaml
-            config:
-              options:
-                mysec:
-                  type: secret
-                  description: "tell me your secrets"
-
-            # charm.py
-            class MyVMCharm(ops.CharmBase):
-                def __init__(self, framework: ops.Framework):
-                    super().__init__(framework)
-                    framework.observe(self.on.config_changed, self._on_config_changed)
-
-                def _on_config_changed(self, event: ops.ConfigChangedEvent):
-                    mysec = self.config.get('mysec')
-                    if mysec:
-                        sec = self.model.get_secret(id=mysec, label="mysec")
-                        self.config_from_secret = sec.get_content()
-
-            # test_charm.py
-            def test_config_changed(harness):
-                secret_content = {'password': 'foo'}
-                secret_id = harness.add_user_secret(secret_content)
-                harness.grant_secret(secret_id, 'test-charm')
-                harness.begin()
-                harness.update_config({'mysec': secret_id})
-                secret = harness.model.get_secret(id=secret_id).get_content()
-                assert harness.charm.config_from_secret == secret.get_content()
-
-        """
-        model.Secret._validate_content(content)
-        # Although it's named a user-owned secret in Juju, technically, the owner is the
-        # Model, so the secret's owner is set to `Model.uuid`.
-        return self._backend._secret_add(content, self.model.uuid)
-
-    def _ensure_secret(self, secret_id: str) -> '_Secret':
-        secret = self._backend._get_secret(secret_id)
-        if secret is None:
-            raise RuntimeError(f'Secret {secret_id!r} not found')
-        return secret
-
-    def set_secret_content(self, secret_id: str, content: Dict[str, str]):
-        """Update a secret's content, add a new revision, and fire *secret-changed*.
-
-        Args:
-            secret_id: The ID of the secret to update. This should normally be
-                the return value of :meth:`add_model_secret`.
-            content: A key-value mapping containing the new payload.
-        """
-        model.Secret._validate_content(content)
-        secret = self._ensure_secret(secret_id)
-        if secret.owner_name in [self.model.app.name, self.model.unit.name]:
-            raise RuntimeError(
-                f'Secret {secret_id!r} owned by the charm under test, '
-                f"can't call set_secret_content"
-            )
-        new_revision = _SecretRevision(
-            revision=secret.revisions[-1].revision + 1,
-            content=content,
-        )
-        secret.revisions.append(new_revision)
-        self.charm.on.secret_changed.emit(secret_id, secret.label)
-
-    def grant_secret(self, secret_id: str, observer: AppUnitOrName):
-        """Grant read access to this secret for the given observer application or unit.
-
-        For user secrets, grant access to the application, simulating the
-        ``juju grant-secret`` command.
-
-        If the given application or unit has already been granted access to
-        this secret, do nothing.
-
-        Args:
-            secret_id: The ID of the secret to grant access to. This should
-                normally be the return value of :meth:`add_model_secret`.
-            observer: The name of the application (or specific unit) to grant
-                access to. A relation between this application and the charm
-                under test must already have been created.
-        """
-        secret = self._ensure_secret(secret_id)
-        app_or_unit_name = _get_app_or_unit_name(observer)
-
-        # User secrets:
-        if secret.owner_name == self.model.uuid:
-            secret.user_secrets_grants.add(app_or_unit_name)
-            return
-
-        # Model secrets:
-        if secret.owner_name in [self.model.app.name, self.model.unit.name]:
-            raise RuntimeError(
-                f"Secret {secret_id!r} owned by the charm under test, can't call grant_secret"
-            )
-        relation_id = self._secret_relation_id_to(secret)
-        if relation_id not in secret.grants:
-            secret.grants[relation_id] = set()
-        secret.grants[relation_id].add(app_or_unit_name)
-
-    def revoke_secret(self, secret_id: str, observer: AppUnitOrName):
-        """Revoke read access to this secret for the given observer application or unit.
-
-        If the given application or unit does not have access to this secret,
-        do nothing.
-
-        Args:
-            secret_id: The ID of the secret to revoke access for. This should
-                normally be the return value of :meth:`add_model_secret`.
-            observer: The name of the application (or specific unit) to revoke
-                access to. A relation between this application and the charm under
-                test must have already been created.
-        """
-        secret = self._ensure_secret(secret_id)
-        app_or_unit_name = _get_app_or_unit_name(observer)
-
-        # User secrets:
-        if secret.owner_name == self.model.uuid:
-            secret.user_secrets_grants.discard(app_or_unit_name)
-            return
-
-        # Model secrets:
-        if secret.owner_name in [self.model.app.name, self.model.unit.name]:
-            raise RuntimeError(
-                f'Secret {secret_id!r} owned by the charm under test, "'
-                f"can't call revoke_secret"
-            )
-
-        relation_id = self._secret_relation_id_to(secret)
-        if relation_id not in secret.grants:
-            return
-        secret.grants[relation_id].discard(app_or_unit_name)
-
-    def _secret_relation_id_to(self, secret: '_Secret') -> int:
-        """Get the relation ID of relation between this charm and the secret owner."""
-        owner_app = secret.owner_name.split('/')[0]
-        relation_id = self._backend._relation_id_to(owner_app)
-        if relation_id is None:
-            raise RuntimeError(
-                f'No relation between this charm ({self.model.app.name}) '
-                f'and secret owner ({owner_app})'
-            )
-        return relation_id
-
-    def get_secret_grants(self, secret_id: str, relation_id: int) -> Set[str]:
-        """Return the set of app and unit names granted to secret for this relation.
-
-        Args:
-            secret_id: The ID of the secret to get grants for.
-            relation_id: The ID of the relation granted access.
-        """
-        secret = self._ensure_secret(secret_id)
-        return secret.grants.get(relation_id, set())
-
-    def get_secret_revisions(self, secret_id: str) -> List[int]:
-        """Return the list of revision IDs for the given secret, oldest first.
-
-        Args:
-            secret_id: The ID of the secret to get revisions for.
-        """
-        secret = self._ensure_secret(secret_id)
-        return [r.revision for r in secret.revisions]
-
-    def trigger_secret_rotation(self, secret_id: str, *, label: Optional[str] = None):
-        """Trigger a secret-rotate event for the given secret.
-
-        This event is fired by Juju when a secret's rotation time elapses,
-        however, time-based events cannot be simulated appropriately in the
-        harness, so this fires it manually.
-
-        Args:
-            secret_id: The ID of the secret associated with the event.
-            label: Label value to send to the event. If None, the secret's
-                label is used.
-        """
-        secret = self._ensure_secret(secret_id)
-        if secret.owner_name == self.model.uuid:
-            raise RuntimeError('Cannot trigger the secret-rotate event for a user secret.')
-        if label is None:
-            label = secret.label
-        self.charm.on.secret_rotate.emit(secret_id, label)
-
-    def trigger_secret_removal(
-        self, secret_id: str, revision: int, *, label: Optional[str] = None
-    ):
-        """Trigger a secret-remove event for the given secret and revision.
-
-        This event is fired by Juju for a specific revision when all the
-        secret's observers have refreshed to a later revision, however, in the
-        harness call this method to fire the event manually.
-
-        Args:
-            secret_id: The ID of the secret associated with the event.
-            revision: Revision number to provide to the event. This should be
-                an item from the list returned by :meth:`get_secret_revisions`.
-            label: Label value to send to the event. If None, the secret's
-                label is used.
-        """
-        secret = self._ensure_secret(secret_id)
-        if label is None:
-            label = secret.label
-        self.charm.on.secret_remove.emit(secret_id, label, revision)
-
-    def trigger_secret_expiration(
-        self, secret_id: str, revision: int, *, label: Optional[str] = None
-    ):
-        """Trigger a secret-expired event for the given secret.
-
-        This event is fired by Juju when a secret's expiration time elapses,
-        however, time-based events cannot be simulated appropriately in the
-        harness, so this fires it manually.
-
-        Args:
-            secret_id: The ID of the secret associated with the event.
-            revision: Revision number to provide to the event. This should be
-                an item from the list returned by :meth:`get_secret_revisions`.
-            label: Label value to send to the event. If None, the secret's
-                label is used.
-        """
-        secret = self._ensure_secret(secret_id)
-        if secret.owner_name == self.model.uuid:
-            raise RuntimeError('Cannot trigger the secret-expired event for a user secret.')
-        if label is None:
-            label = secret.label
-        self.charm.on.secret_expired.emit(secret_id, label, revision)
-
-    def get_filesystem_root(self, container: Union[str, Container]) -> pathlib.Path:
-        """Return the temp directory path harness will use to simulate the container filesystem.
-
-        In a real container runtime, each container has an isolated root filesystem.
-        To simulate this behaviour, the testing harness manages a temporary directory for
-        each container. Any Pebble filesystem API calls will be translated
-        and mapped to this directory, as if the directory was the container's
-        filesystem root.
-
-        This process is quite similar to the ``chroot`` command. Charm tests should
-        treat the returned directory as the container's root directory (``/``).
-        The testing harness will not create any files or directories inside the
-        simulated container's root directory; it's up to the test to populate the container's
-        root directory with any files or directories the charm needs.
-
-        Regarding the file ownership: unprivileged users are unable to create files with distinct
-        ownership. To circumvent this limitation, the testing harness maps all user and group
-        options related to file operations to match the current user and group.
-
-        Example usage (the parameter ``harness`` in the test function is a pytest fixture
-        that does setup/teardown, see :class:`Harness`)::
-
-            # charm.py
-            class ExampleCharm(ops.CharmBase):
-                def __init__(self, *args):
-                    super().__init__(*args)
-                    self.framework.observe(self.on["mycontainer"].pebble_ready,
-                                           self._on_pebble_ready)
-
-                def _on_pebble_ready(self, event: ops.PebbleReadyEvent):
-                    self.hostname = event.workload.pull("/etc/hostname").read()
-
-            # test_charm.py
-            def test_hostname(harness):
-                root = harness.get_filesystem_root("mycontainer")
-                (root / "etc").mkdir()
-                (root / "etc" / "hostname").write_text("hostname.example.com")
-                harness.begin_with_initial_hooks()
-                assert harness.charm.hostname == "hostname.example.com"
-
-        Args:
-            container: The name of the container or the container instance.
-
-        Return:
-            The path of the temporary directory associated with the specified container.
-        """
-        # It's okay to access the container directly in this context, as its creation has already
-        # been ensured during the model's initialization.
-        container_name = container if isinstance(container, str) else container.name
-        return self._backend._pebble_clients[container_name]._root
-
-    def evaluate_status(self) -> None:
-        """Trigger the collect-status events and set application and/or unit status.
-
-        This will always trigger ``collect_unit_status``, and set the unit status if any
-        statuses were added.
-
-        If running on the leader unit (:meth:`set_leader` has been called with ``True``),
-        this will trigger ``collect_app_status``, and set the application status if any
-        statuses were added.
-
-        Tests should normally call this and then assert that ``self.model.app.status``
-        or ``self.model.unit.status`` is the value expected.
-
-        Evaluation is not "additive"; this method resets the added statuses before
-        triggering each collect-status event.
-        """
-        self.charm.app._collected_statuses = []
-        self.charm.unit._collected_statuses = []
-        charm._evaluate_status(self.charm)
-
-    def handle_exec(
-        self,
-        container: Union[str, Container],
-        command_prefix: Sequence[str],
-        *,
-        handler: Optional[ExecHandler] = None,
-        result: Optional[Union[int, str, bytes, ExecResult]] = None,
-    ):
-        r"""Register a handler to simulate the Pebble command execution.
-
-        This allows a test harness to simulate the behavior of running commands in a container.
-        When :meth:`ops.Container.exec` is triggered, the registered handler is used to
-        generate stdout and stderr for the simulated execution.
-
-        A ``handler`` or a ``result`` may be provided, but not both:
-
-        - A ``handler`` is a function accepting :class:`ops.testing.ExecArgs` and returning
-          :class:`ops.testing.ExecResult` as the simulated process outcome. For cases that
-          have side effects but don't return output, the handler can return ``None``, which
-          is equivalent to returning ``ExecResult()``.
-
-        - A ``result`` is for simulations that don't need to inspect the ``exec`` arguments; the
-          output or exit code is provided directly. Setting ``result`` to str or bytes means
-          use that string as stdout (with exit code 0); setting ``result`` to int means return
-          that exit code (and no stdout).
-
-        If ``handle_exec`` is called more than once with overlapping command prefixes, the
-        longest match takes precedence. The registration of an execution handler can be updated by
-        re-registering with the same command prefix.
-
-        The execution handler receives the timeout value in the ``ExecArgs``. If needed,
-        it can raise a ``TimeoutError`` to inform the harness that a timeout occurred.
-
-        If :meth:`ops.Container.exec` is called with ``combine_stderr=True``, the execution
-        handler should, if required, weave the simulated standard error into the standard output.
-        The harness checks the result and will raise an exception if stderr is non-empty.
-
-        Args:
-            container: The specified container or its name.
-            command_prefix: The command prefix to register against.
-            handler: A handler function that simulates the command's execution.
-            result: A simplified form to specify the command's simulated result.
-
-        Example usage::
-
-            # produce no output and return 0 for every command
-            harness.handle_exec('container', [], result=0)
-
-            # simple example that just produces output (exit code 0)
-            harness.handle_exec('webserver', ['ls', '/etc'], result='passwd\nprofile\n')
-
-            # slightly more complex (use stdin)
-            harness.handle_exec(
-                'c1', ['sha1sum'],
-                handler=lambda args: ExecResult(stdout=hashlib.sha1(args.stdin).hexdigest()))
-
-            # more complex example using args.command
-            def docker_handler(args: testing.ExecArgs) -> testing.ExecResult:
-                match args.command:
-                    case ['docker', 'run', image]:
-                        return testing.ExecResult(stdout=f'running {image}')
-                    case ['docker', 'ps']:
-                        return testing.ExecResult(stdout='CONTAINER ID   IMAGE ...')
-                    case _:
-                        return testing.ExecResult(exit_code=1, stderr='unknown command')
-
-            harness.handle_exec('database', ['docker'], handler=docker_handler)
-
-            # handle timeout
-            def handle_timeout(args: testing.ExecArgs) -> int:
-                if args.timeout is not None and args.timeout < 10:
-                    raise TimeoutError
-                return 0
-
-            harness.handle_exec('database', ['foo'], handler=handle_timeout)
-        """
-        if (handler is None and result is None) or (handler is not None and result is not None):
-            raise TypeError('Either handler or result must be provided, but not both.')
-        container_name = container if isinstance(container, str) else container.name
-        if result is not None:
-            if isinstance(result, int) and not isinstance(result, bool):
-                result = ExecResult(exit_code=result)
-            elif isinstance(result, (str, bytes)):
-                result = ExecResult(stdout=result)
-            elif not isinstance(result, ExecResult):
-                raise TypeError(
-                    f'result must be int, str, bytes, or ExecResult, '
-                    f'not {result.__class__.__name__}'
-                )
-        self._backend._pebble_clients[container_name]._handle_exec(
-            command_prefix=command_prefix,
-            handler=(lambda _: result) if handler is None else handler,  # type: ignore
-        )
-
-    @property
-    def reboot_count(self) -> int:
-        """Number of times the charm has called :meth:`ops.Unit.reboot`."""
-        return self._backend._reboot_count
-
-    def run_action(
-        self, action_name: str, params: Optional[Dict[str, Any]] = None
-    ) -> ActionOutput:
-        """Simulates running a charm action, as with ``juju run``.
-
-        Use this only after calling :meth:`begin`.
-
-        Validates that no required parameters are missing, and that additional
-        parameters are not provided if that is not permitted. Does not validate
-        the types of the parameters - you can use the
-        `jsonschema <https://github.com/python-jsonschema/jsonschema>`_ package to
-        do this in your tests; for example::
-
-            schema = harness.charm.meta.actions["action-name"].parameters
-            try:
-                jsonschema.validate(instance=params, schema=schema)
-            except jsonschema.ValidationError:
-                # Do something about the invalid params.
-                ...
-            harness.run_action("action-name", params)
-
-        Args:
-            action_name: the name of the action to run, as found in ``actions.yaml``.
-            params: override the default parameter values found in ``actions.yaml``.
-                If a parameter is not in ``params``, or ``params`` is ``None``, then
-                the default value from ``actions.yaml`` will be used.
-
-        Raises:
-            ActionFailed: if :meth:`ops.ActionEvent.fail` is called. Note that this will
-              be raised at the end of the ``run_action`` call, not immediately when
-              :code:`fail()` is called, to match the run-time behaviour.
-        """
-        try:
-            action_meta = self.charm.meta.actions[action_name]
-        except KeyError:
-            raise RuntimeError(f'Charm does not have a {action_name!r} action.') from None
-        if params is None:
-            params = {}
-        for key in action_meta.required:
-            # Juju requires that the key is in the passed parameters, even if there is a default
-            # value in actions.yaml.
-            if key not in params:
-                raise RuntimeError(f'{key!r} parameter is required, but missing.')
-        if not action_meta.additional_properties:
-            for key in params:
-                if key not in action_meta.parameters:
-                    # Match Juju's error message.
-                    raise model.ModelError(
-                        f'additional property "{key}" is not allowed, '
-                        f'given {{"{key}":{params[key]!r}}}'
-                    )
-        action_under_test = _RunningAction(action_name, ActionOutput([], {}), params)
-        handler = getattr(self.charm.on, f"{action_name.replace('-', '_')}_action")
-        self._backend._running_action = action_under_test
-        self._action_id_counter += 1
-        handler.emit(str(self._action_id_counter))
-        self._backend._running_action = None
-        if action_under_test.failure_message is not None:
-            raise ActionFailed(
-                message=action_under_test.failure_message, output=action_under_test.output
-            )
-        return action_under_test.output
-
-    def set_cloud_spec(self, spec: 'model.CloudSpec'):
-        """Set cloud specification (metadata) including credentials.
-
-        Call this method before the charm calls :meth:`ops.Model.get_cloud_spec`.
-
-        Example usage (the parameter ``harness`` in the test function is
-        a pytest fixture that does setup/teardown, see :class:`Harness`)::
-
-            # charm.py
-            class MyVMCharm(ops.CharmBase):
-                def __init__(self, framework: ops.Framework):
-                    super().__init__(framework)
-                    framework.observe(self.on.start, self._on_start)
-
-                def _on_start(self, event: ops.StartEvent):
-                    self.cloud_spec = self.model.get_cloud_spec()
-
-            # test_charm.py
-            def test_start(harness):
-                cloud_spec = ops.model.CloudSpec.from_dict({
-                    'name': 'localhost',
-                    'type': 'lxd',
-                    'endpoint': 'https://127.0.0.1:8443',
-                    'credential': {
-                        'auth-type': 'certificate',
-                        'attrs': {
-                            'client-cert': 'foo',
-                            'client-key': 'bar',
-                            'server-cert': 'baz'
-                        },
-                    },
-                })
-                harness.set_cloud_spec(cloud_spec)
-                harness.begin()
-                harness.charm.on.start.emit()
-                assert harness.charm.cloud_spec == cloud_spec
-
-        """
-        self._backend._cloud_spec = spec
-
-
-def _get_app_or_unit_name(app_or_unit: AppUnitOrName) -> str:
-    """Return name of given application or unit (return strings directly)."""
-    if isinstance(app_or_unit, (model.Application, model.Unit)):
-        return app_or_unit.name
-    elif isinstance(app_or_unit, str):
-        return app_or_unit
-    else:
-        raise TypeError(f'Expected Application | Unit | str, got {type(app_or_unit)}')
-
-
-def _record_calls(cls: Any):
-    """Replace methods on cls with methods that record that they have been called.
-
-    Iterate all attributes of cls, and for public methods, replace them with a wrapped method
-    that records the method called along with the arguments and keyword arguments.
-    """
-    for meth_name, orig_method in cls.__dict__.items():
-        if meth_name.startswith('_'):
-            continue
-
-        def decorator(orig_method: Any):
-            def wrapped(self: '_TestingModelBackend', *args: Any, **kwargs: Any):
-                full_args = (orig_method.__name__, *args)
-                if kwargs:
-                    full_args = (*full_args, kwargs)
-                self._calls.append(full_args)
-                return orig_method(self, *args, **kwargs)
-
-            return wrapped
-
-        setattr(cls, meth_name, decorator(orig_method))
-    return cls
-
-
-def _copy_docstrings(source_cls: Any):
-    """Copy the docstrings from source_cls to target_cls.
-
-    Use this as:
-      @_copy_docstrings(source_class)
-      class TargetClass:
-
-    And for any public method that exists on both classes, it will copy the
-    __doc__ for that method.
-    """
-
-    def decorator(target_cls: Any):
-        for meth_name in target_cls.__dict__:
-            if meth_name.startswith('_'):
-                continue
-            source_method = source_cls.__dict__.get(meth_name)
-            if source_method is not None and source_method.__doc__:
-                target_cls.__dict__[meth_name].__doc__ = source_method.__doc__
-        return target_cls
-
-    return decorator
-
-
-@_record_calls
-class _TestingConfig(Dict[str, Union[str, int, float, bool]]):
-    """Represents the Juju Config."""
-
-    _supported_types = {
-        'string': str,
-        'boolean': bool,
-        'int': int,
-        'float': float,
-        'secret': str,  # There is some special structure, but they are strings.
-    }
-
-    def __init__(self, config: '_RawConfig'):
-        super().__init__()
-        self._spec = config
-        self._defaults = self._load_defaults(config)
-
-        for key, value in self._defaults.items():
-            if value is None:
-                continue
-            self._config_set(key, value)
-
-    @staticmethod
-    def _load_defaults(charm_config: '_RawConfig') -> Dict[str, Union[str, int, float, bool]]:
-        """Load default values from config.yaml.
-
-        Handle the case where a user doesn't supply explicit config snippets.
-        """
-        if not charm_config:
-            return {}
-        cfg: Dict[str, _ConfigOption] = charm_config.get('options', {})
-        return {key: value.get('default', None) for key, value in cfg.items()}
-
-    def _config_set(self, key: str, value: Union[str, int, float, bool]):
-        # this is only called by the harness itself
-        # we don't do real serialization/deserialization, but we do check that the value
-        # has the expected type.
-        option = self._spec.get('options', {}).get(key)
-        if not option:
-            raise RuntimeError(
-                f'Unknown config option {key}; '
-                'not declared in `config.yaml`.'
-                'Check https://juju.is/docs/sdk/config for the '
-                'spec.'
-            )
-
-        declared_type = option.get('type')
-        if not declared_type:
-            raise RuntimeError(
-                f'Incorrectly formatted `options.yaml`, option {key} '
-                'is expected to declare a `type`.'
-            )
-
-        if declared_type not in self._supported_types:
-            raise RuntimeError(
-                'Incorrectly formatted `options.yaml`: `type` needs to be one '
-                'of [{}], not {}.'.format(', '.join(self._supported_types), declared_type)
-            )
-
-        if type(value) is not self._supported_types[declared_type]:
-            raise RuntimeError(
-                f'Config option {key} is supposed to be of type '
-                f'{declared_type}, not `{type(value).__name__}`.'
-            )
-
-        # call 'normal' setattr.
-        dict.__setitem__(self, key, value)  # type: ignore
-
-    def __setitem__(self, key: Any, value: Any):
-        # if a charm attempts to config[foo] = bar:
-        raise TypeError("'ConfigData' object does not support item assignment")
-
-
-class _TestingRelationDataContents(Dict[str, str]):
-    def __setitem__(self, key: str, value: str):
-        if not isinstance(key, str):
-            raise model.RelationDataError(f'relation data keys must be strings, not {type(key)}')
-        if not isinstance(value, str):
-            raise model.RelationDataError(
-                f'relation data values must be strings, not {type(value)}'
-            )
-        super().__setitem__(key, value)
-
-    def copy(self):
-        return _TestingRelationDataContents(super().copy())
-
-
-@dataclasses.dataclass
-class _SecretRevision:
-    revision: int
-    content: Dict[str, str]
-
-
-@dataclasses.dataclass
-class _Secret:
-    id: str
-    owner_name: str
-    revisions: List[_SecretRevision]
-    rotate_policy: Optional[str]
-    expire_time: Optional[datetime.datetime]
-    label: Optional[str] = None
-    description: Optional[str] = None
-    tracked: int = 1
-    grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict)
-    user_secrets_grants: Set[str] = dataclasses.field(default_factory=set)
-
-
-@_copy_docstrings(model._ModelBackend)
-@_record_calls
-class _TestingModelBackend:
-    """This conforms to the interface for ModelBackend but provides canned data.
-
-    DO NOT use this class directly, it is used by `Harness`_ to drive the model.
-    `Harness`_ is responsible for maintaining the internal consistency of the values here,
-    as the only public methods of this type are for implementing ModelBackend.
-    """
-
-    def __init__(
-        self,
-        unit_name: str,
-        meta: charm.CharmMeta,
-        config: '_RawConfig',
-        juju_context: _JujuContext,
-    ):
-        self._juju_context = juju_context
-        self.unit_name = unit_name
-        self.app_name = self.unit_name.split('/')[0]
-        self.model_name = None
-        self.model_uuid = str(uuid.uuid4())
-
-        self._harness_tmp_dir = tempfile.TemporaryDirectory(prefix='ops-harness-')
-        self._harness_storage_path = pathlib.Path(self._harness_tmp_dir.name) / 'storages'
-        self._harness_container_path = pathlib.Path(self._harness_tmp_dir.name) / 'containers'
-        self._harness_storage_path.mkdir()
-        self._harness_container_path.mkdir()
-        # this is used by the _record_calls decorator
-        self._calls: List[Tuple[Any, ...]] = []
-        self._meta = meta
-        # relation name to [relation_ids,...]
-        self._relation_ids_map: Dict[str, List[int]] = {}
-        # reverse map from relation_id to relation_name
-        self._relation_names: Dict[int, str] = {}
-        # relation_id: [unit_name,...]
-        self._relation_list_map: Dict[int, List[str]] = {}
-        # {relation_id: {name: Dict[str: str]}}
-        self._relation_data_raw: Dict[int, Dict[str, Dict[str, str]]] = {}
-        # {relation_id: {"app": app_name, "units": ["app/0",...]}
-        self._relation_app_and_units: Dict[int, _RelationEntities] = {}
-        self._config = _TestingConfig(config)
-        self._is_leader: bool = False
-        # {resource_name: resource_content}
-        # where resource_content is (path, content)
-        self._resources_map: Dict[str, Tuple[str, Union[str, bytes]]] = {}
-        # fixme: understand how this is used and adjust the type
-        self._pod_spec: Optional[Tuple[model.K8sSpec, Any]] = None
-        self._app_status: _RawStatus = {'status': 'unknown', 'message': ''}
-        self._unit_status: _RawStatus = {'status': 'maintenance', 'message': ''}
-        self._workload_version: Optional[str] = None
-        self._resource_dir: Optional[tempfile.TemporaryDirectory[Any]] = None
-        # Format:
-        # { "storage_name": {"<ID1>": { <other-properties> }, ... }
-        # <ID1>: device id that is key for given storage_name
-        # Initialize the _storage_list with values present on metadata.yaml
-        self._storage_list: Dict[str, Dict[int, Dict[str, Any]]] = {
-            k: {} for k in self._meta.storages
-        }
-        self._storage_attached: Dict[str, Set[int]] = {k: set() for k in self._meta.storages}
-        self._storage_index_counter = 0
-        # {container_name : _TestingPebbleClient}
-        self._pebble_clients: Dict[str, _TestingPebbleClient] = {}
-        self._pebble_clients_can_connect: Dict[_TestingPebbleClient, bool] = {}
-        self._planned_units: Optional[int] = None
-        self._hook_is_running = ''
-        self._secrets: List[_Secret] = []
-        self._opened_ports: Set[model.Port] = set()
-        self._networks: Dict[Tuple[Optional[str], Optional[int]], _NetworkDict] = {}
-        self._reboot_count = 0
-        self._running_action: Optional[_RunningAction] = None
-        self._cloud_spec: Optional[model.CloudSpec] = None
-
-    def _can_connect(self, pebble_client: '_TestingPebbleClient') -> bool:
-        """Returns whether the mock client is active and can support API calls with no errors."""
-        return self._pebble_clients_can_connect[pebble_client]
-
-    def _set_can_connect(self, pebble_client: '_TestingPebbleClient', val: bool):
-        """Manually sets the can_connect state for the given mock client."""
-        if pebble_client not in self._pebble_clients_can_connect:
-            msg = 'cannot set can_connect for the client - are you running a "real" pebble test?'
-            raise RuntimeError(msg)
-        self._pebble_clients_can_connect[pebble_client] = val
-
-    def _cleanup(self):
-        if self._resource_dir is not None:
-            self._resource_dir.cleanup()
-            self._resource_dir = None
-        self._harness_tmp_dir.cleanup()
-
-    def _get_resource_dir(self) -> pathlib.Path:
-        if self._resource_dir is None:
-            # In actual Juju, the resource path for a charm's resource is
-            # $AGENT_DIR/resources/$RESOURCE_NAME/$RESOURCE_FILENAME
-            # However, charms shouldn't depend on this.
-            self._resource_dir = tempfile.TemporaryDirectory(prefix='tmp-ops-test-resource-')
-        res_dir_name = cast(str, self._resource_dir.name)
-        return pathlib.Path(res_dir_name)
-
-    def relation_ids(self, relation_name: str) -> List[int]:
-        try:
-            return self._relation_ids_map[relation_name]
-        except KeyError:
-            if relation_name not in self._meta.relations:
-                raise model.ModelError(f'{relation_name} is not a known relation') from None
-            no_ids: List[int] = []
-            return no_ids
-
-    def relation_list(self, relation_id: int):
-        try:
-            return self._relation_list_map[relation_id]
-        except KeyError:
-            raise model.RelationNotFoundError from None
-
-    def relation_remote_app_name(self, relation_id: int) -> Optional[str]:
-        if relation_id not in self._relation_app_and_units:
-            # Non-existent or dead relation
-            return None
-        return self._relation_app_and_units[relation_id]['app']
-
-    def relation_get(self, relation_id: int, member_name: str, is_app: bool):
-        if is_app and '/' in member_name:
-            member_name = member_name.split('/')[0]
-        if relation_id not in self._relation_data_raw:
-            raise model.RelationNotFoundError()
-        return self._relation_data_raw[relation_id][member_name]
-
-    def update_relation_data(
-        self, relation_id: int, _entity: Union[model.Unit, model.Application], key: str, value: str
-    ):
-        # this is where the 'real' backend would call relation-set.
-        raw_data = self._relation_data_raw[relation_id][_entity.name]
-        if value == '':
-            raw_data.pop(key, None)
-        else:
-            raw_data[key] = value
-
-    def relation_set(self, relation_id: int, key: str, value: str, is_app: bool):
-        if not isinstance(is_app, bool):
-            raise TypeError('is_app parameter to relation_set must be a boolean')
-
-        if 'relation_broken' in self._hook_is_running and not self.relation_remote_app_name(
-            relation_id
-        ):
-            raise RuntimeError(
-                'remote-side relation data cannot be accessed during a relation-broken event'
-            )
-
-        if relation_id not in self._relation_data_raw:
-            raise RelationNotFoundError(relation_id)
-
-        relation = self._relation_data_raw[relation_id]
-        bucket_key = self.app_name if is_app else self.unit_name
-        if bucket_key not in relation:
-            relation[bucket_key] = {}
-        bucket = relation[bucket_key]
-        if value == '':
-            bucket.pop(key, None)
-        else:
-            bucket[key] = value
-
-    def config_get(self) -> _TestingConfig:
-        return self._config
-
-    def is_leader(self):
-        return self._is_leader
-
-    def application_version_set(self, version: str):
-        self._workload_version = version
-
-    def resource_get(self, resource_name: str):
-        if resource_name not in self._resources_map:
-            raise model.ModelError(
-                'ERROR could not download resource: HTTP request failed: '
-                'Get https://.../units/unit-{}/resources/{}: resource#{}/{} not found'.format(
-                    self.unit_name.replace('/', '-'), resource_name, self.app_name, resource_name
-                )
-            )
-        filename, contents = self._resources_map[resource_name]
-        resource_dir = self._get_resource_dir()
-        resource_filename = resource_dir / resource_name / filename
-        if not resource_filename.exists():
-            mode = 'wb' if isinstance(contents, bytes) else 'wt'
-            resource_filename.parent.mkdir(exist_ok=True)
-            with resource_filename.open(mode=mode) as resource_file:
-                resource_file.write(contents)
-        return resource_filename
-
-    def pod_spec_set(self, spec: 'model.K8sSpec', k8s_resources: Any):  # fixme: any
-        self._pod_spec = (spec, k8s_resources)
-
-    def status_get(self, *, is_app: bool = False):
-        if is_app:
-            return self._app_status
-        else:
-            return self._unit_status
-
-    def status_set(self, status: '_StatusName', message: str = '', *, is_app: bool = False):
-        if status in [model.ErrorStatus.name, model.UnknownStatus.name]:
-            raise model.ModelError(
-                f'ERROR invalid status "{status}", expected one of'
-                ' [maintenance blocked waiting active]'
-            )
-        if is_app:
-            self._app_status = {'status': status, 'message': message}
-        else:
-            self._unit_status = {'status': status, 'message': message}
-
-    def storage_list(self, name: str, include_detached: bool = False):
-        """Returns a list of all attached storage mounts for the given storage name.
-
-        Args:
-            name: name (i.e. from metadata.yaml).
-            include_detached: True to include unattached storage mounts as well.
-        """
-        return [
-            index
-            for index in self._storage_list[name]
-            if include_detached or self._storage_is_attached(name, index)
-        ]
-
-    def storage_get(self, storage_name_id: str, attribute: str) -> Any:
-        name, index = storage_name_id.split('/', 1)
-        index = int(index)
-        try:
-            if index not in self._storage_attached[name]:
-                raise KeyError()  # Pretend the key isn't there
-            else:
-                return self._storage_list[name][index][attribute]
-        except KeyError:
-            raise model.ModelError(
-                f'ERROR invalid value "{name}/{index}" for option -s: storage not found'
-            ) from None
-
-    def storage_add(self, name: str, count: int = 1) -> List[int]:
-        if '/' in name:
-            raise model.ModelError('storage name cannot contain "/"')
-
-        if name not in self._storage_list:
-            self._storage_list[name] = {}
-        result: List[int] = []
-        for _ in range(count):
-            index = self._storage_index_counter
-            self._storage_index_counter += 1
-            self._storage_list[name][index] = {
-                'location': os.path.join(self._harness_storage_path, name, str(index)),
-            }
-            result.append(index)
-        return result
-
-    def _storage_detach(self, storage_id: str):
-        # NOTE: This is an extra function for _TestingModelBackend to simulate
-        # detachment of a storage unit.  This is not present in ops.model._ModelBackend.
-        name, index = storage_id.split('/', 1)
-        index = int(index)
-
-        for container, client in self._pebble_clients.items():
-            for mount in self._meta.containers[container].mounts.values():
-                if mount.storage != name:
-                    continue
-                root = client._root
-                (root / mount.location[1:]).unlink()
-
-        if self._storage_is_attached(name, index):
-            self._storage_attached[name].remove(index)
-
-    def _storage_attach(self, storage_id: str):
-        """Mark the named storage_id as attached and return True if it was previously detached."""
-        # NOTE: This is an extra function for _TestingModelBackend to simulate
-        # re-attachment of a storage unit.  This is not present in
-        # ops.model._ModelBackend.
-        name, index = storage_id.split('/', 1)
-
-        for container, client in self._pebble_clients.items():
-            for mount in self._meta.containers[container].mounts.values():
-                if mount.storage != name:
-                    continue
-                for store in self._storage_list[mount.storage].values():
-                    root = client._root
-                    mounting_dir = root / mount.location[1:]
-                    mounting_dir.parent.mkdir(parents=True, exist_ok=True)
-                    target_dir = pathlib.Path(store['location'])
-                    target_dir.mkdir(parents=True, exist_ok=True)
-                    try:
-                        mounting_dir.symlink_to(target_dir, target_is_directory=True)
-                    except FileExistsError:
-                        # If the symlink is already the one we want, then we
-                        # don't need to do anything here.
-                        # NOTE: In Python 3.9, this can use `mounting_dir.readlink()`
-                        if not mounting_dir.is_symlink() or os.readlink(mounting_dir) != str(
-                            target_dir
-                        ):
-                            raise
-
-        index = int(index)
-        if not self._storage_is_attached(name, index):
-            self._storage_attached[name].add(index)
-            return True
-        return False
-
-    def _storage_is_attached(self, storage_name: str, storage_index: int):
-        return storage_index in self._storage_attached[storage_name]
-
-    def _storage_remove(self, storage_id: str):
-        # NOTE: This is an extra function for _TestingModelBackend to simulate
-        # full removal of a storage unit.  This is not present in
-        # ops.model._ModelBackend.
-        self._storage_detach(storage_id)
-        name, index = storage_id.split('/', 1)
-        index = int(index)
-        self._storage_list[name].pop(index, None)
-
-    def action_get(self) -> Dict[str, Any]:
-        params: Dict[str, Any] = {}
-        assert self._running_action is not None
-        action_meta = self._meta.actions[self._running_action.name]
-        for name, meta in action_meta.parameters.items():
-            if 'default' in meta:
-                params[name] = meta['default']
-        params.update(self._running_action.parameters)
-        return params
-
-    def action_set(self, results: Dict[str, Any]):
-        assert self._running_action is not None
-        for key in ('stdout', 'stderr', 'stdout-encoding', 'stderr-encoding'):
-            if key in results:
-                # Match Juju's error message.
-                raise model.ModelError(f'ERROR cannot set reserved action key "{key}"')
-        # Although it's not necessary, we go through the same flattening process
-        # as the real backend, in order to give Charmers advance notice if they
-        # are setting results that will not work.
-        # This also does some validation on keys to make sure that they fit the
-        # Juju constraints.
-        model._format_action_result_dict(results)  # Validate, but ignore returned value.
-        self._running_action.output.results.update(results)
-
-    def action_log(self, message: str):
-        assert self._running_action is not None
-        self._running_action.output.logs.append(message)
-
-    def action_fail(self, message: str = ''):
-        assert self._running_action is not None
-        # If fail is called multiple times, Juju only retains the most recent failure message.
-        self._running_action.failure_message = message
-
-    def network_get(self, endpoint_name: str, relation_id: Optional[int] = None) -> '_NetworkDict':
-        data = self._networks.get((endpoint_name, relation_id))
-        if data is not None:
-            return data
-        if relation_id is not None:
-            # Fall back to the default binding for this endpoint
-            data = self._networks.get((endpoint_name, None))
-            if data is not None:
-                return data
-        # No custom data per relation ID or binding, return the default binding
-        data = self._networks.get((None, None))
-        if data is not None:
-            return data
-        raise RelationNotFoundError
-
-    def add_metrics(self, metrics, labels=None):  # type:ignore
-        raise NotImplementedError(self.add_metrics)  # type:ignore
-
-    @classmethod
-    def log_split(cls, message, max_len=model.MAX_LOG_LINE_LEN):  # type:ignore
-        raise NotImplementedError(cls.log_split)  # type:ignore
-
-    def juju_log(self, level, msg):  # type:ignore
-        raise NotImplementedError(self.juju_log)  # type:ignore
-
-    def get_pebble(self, socket_path: str) -> '_TestingPebbleClient':
-        container = socket_path.split('/')[3]  # /charm/containers/<container_name>/pebble.socket
-        client = self._pebble_clients.get(container, None)
-        if client is None:
-            container_root = self._harness_container_path / container
-            container_root.mkdir()
-            client = _TestingPebbleClient(self, container_root=container_root)
-
-            # we need to know which container a new pebble client belongs to
-            # so we can figure out which storage mounts must be simulated on
-            # this pebble client's mock file systems when storage is
-            # attached/detached later.
-            self._pebble_clients[container] = client
-
-        self._pebble_clients_can_connect[client] = False
-        return client
-
-    def planned_units(self) -> int:
-        """Simulate fetching the number of planned application units from the model.
-
-        If self._planned_units is None, then we simulate what the Juju controller will do, which is
-        to report the number of peers, plus one (we include this unit in the count). This can be
-        overridden for testing purposes: a charm author can set the number of planned units
-        explicitly by calling `Harness.set_planned_units`
-        """
-        if self._planned_units is not None:
-            return self._planned_units
-
-        units: Set[str] = set()
-        peer_names: Set[str] = set(self._meta.peers.keys())
-        for peer_id, peer_name in self._relation_names.items():
-            if peer_name not in peer_names:
-                continue
-            peer_units = self._relation_list_map[peer_id]
-            units.update(peer_units)
-
-        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 self._secret_ids_are_equal(s.id, id)), None)
-
-    def _ensure_secret(self, id: str) -> _Secret:
-        secret = self._get_secret(id)
-        if secret is None:
-            raise model.SecretNotFoundError(f'Secret {id!r} not found')
-        return secret
-
-    def _ensure_secret_id_or_label(self, id: Optional[str], label: Optional[str]):
-        secret = None
-        if id is not None:
-            secret = self._get_secret(id)
-            if secret is not None and label is not None:
-                secret.label = label  # both id and label given, update label
-        if secret is None and label is not None:
-            secret = next((s for s in self._secrets if s.label == label), None)
-        if secret is None:
-            raise model.SecretNotFoundError(
-                f'Secret not found by ID ({id!r}) or label ({label!r})'
-            )
-        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,
-        *,
-        id: Optional[str] = None,
-        label: Optional[str] = None,
-        refresh: bool = False,
-        peek: bool = False,
-    ) -> Dict[str, str]:
-        secret = self._ensure_secret_id_or_label(id, label)
-
-        if secret.owner_name == self.model_uuid:
-            # This is a user secret - charms only ever have view access.
-            if self.app_name not in secret.user_secrets_grants:
-                raise model.SecretNotFoundError(
-                    f'Secret {id!r} not granted access to {self.app_name!r}'
-                )
-        elif secret.owner_name not in [self.app_name, self.unit_name]:
-            # This is a model secret - the model might have admin or view access.
-            # Check that caller has permission to get this secret
-            # Observer is calling: does secret have a grant on relation between
-            # this charm (the observer) and the secret owner's app?
-            owner_app = secret.owner_name.split('/')[0]
-            relation_id = self._relation_id_to(owner_app)
-            if relation_id is None:
-                raise model.SecretNotFoundError(
-                    f'Secret {id!r} does not have relation to {owner_app!r}'
-                )
-            grants = secret.grants.get(relation_id, set())
-            if self.app_name not in grants and self.unit_name not in grants:
-                raise model.SecretNotFoundError(
-                    f'Secret {id!r} not granted access to {self.app_name!r} or {self.unit_name!r}'
-                )
-
-        if peek or refresh:
-            revision = secret.revisions[-1]
-            if refresh:
-                secret.tracked = revision.revision
-        else:
-            revision = next((r for r in secret.revisions if r.revision == secret.tracked), None)
-            if revision is None:
-                raise model.SecretNotFoundError(f'Secret {id!r} tracked revision was removed')
-
-        return revision.content
-
-    def _relation_id_to(self, remote_app: str) -> Optional[int]:
-        """Return relation ID of relation from charm's app to remote app."""
-        for relation_id, app_units in self._relation_app_and_units.items():
-            if app_units['app'] == remote_app:
-                return relation_id
-        return None
-
-    def _has_secret_owner_permission(self, secret: _Secret) -> bool:
-        # For unit secrets, the owner unit has manage permissions. For app
-        # secrets, the leader has manage permissions and other units only have
-        # view permissions.
-        # https://discourse.charmhub.io/t/secret-access-permissions/12627
-        # For user secrets the secret owner is the model, that is,
-        # when `secret.owner_name == self.model.uuid`, only model admins have
-        # manage permissions: https://juju.is/docs/juju/secret.
-
-        unit_secret = secret.owner_name == self.unit_name
-        app_secret = secret.owner_name == self.app_name
-
-        if unit_secret or (app_secret and self.is_leader()):
-            return True
-        return False
-
-    def secret_info_get(
-        self, *, id: Optional[str] = None, label: Optional[str] = None
-    ) -> model.SecretInfo:
-        secret = self._ensure_secret_id_or_label(id, label)
-        if not self._has_secret_owner_permission(secret):
-            raise model.SecretNotFoundError(
-                f'You must own secret {secret.id!r} to perform this operation'
-            )
-
-        rotates = None
-        rotation = None
-        if secret.rotate_policy is not None:
-            rotation = model.SecretRotate(secret.rotate_policy)
-            if secret.rotate_policy != model.SecretRotate.NEVER:
-                # Just set a fake rotation time some time in the future
-                rotates = datetime.datetime.now() + datetime.timedelta(days=1)
-
-        return model.SecretInfo(
-            id=secret.id,
-            label=secret.label,
-            revision=secret.tracked,
-            expires=secret.expire_time,
-            rotation=rotation,
-            rotates=rotates,
-            description=secret.description,
-            model_uuid=self.model_uuid,
-        )
-
-    def secret_set(
-        self,
-        id: str,
-        *,
-        content: Optional[Dict[str, str]] = None,
-        label: Optional[str] = None,
-        description: Optional[str] = None,
-        expire: Optional[datetime.datetime] = None,
-        rotate: Optional[model.SecretRotate] = None,
-    ) -> None:
-        secret = self._ensure_secret(id)
-        if not self._has_secret_owner_permission(secret):
-            raise RuntimeError(f'You must own secret {secret.id!r} to perform this operation')
-
-        if content is None:
-            content = secret.revisions[-1].content
-        revision = _SecretRevision(revision=secret.revisions[-1].revision + 1, content=content)
-        secret.revisions.append(revision)
-        if label is not None:
-            if label:
-                secret.label = label
-            else:
-                secret.label = None  # clear label
-        if description is not None:
-            if description:
-                secret.description = description
-            else:
-                secret.description = None  # clear description
-        if expire is not None:
-            secret.expire_time = expire
-        if rotate is not None:
-            if rotate != model.SecretRotate.NEVER:
-                secret.rotate_policy = rotate.value
-            else:
-                secret.rotate_policy = None  # clear rotation policy
-
-    @classmethod
-    def _generate_secret_id(cls) -> str:
-        # Not a proper Juju secrets-style xid, but that's okay
-        return f'secret:{uuid.uuid4()}'
-
-    def secret_add(
-        self,
-        content: Dict[str, str],
-        *,
-        label: Optional[str] = None,
-        description: Optional[str] = None,
-        expire: Optional[datetime.datetime] = None,
-        rotate: Optional[model.SecretRotate] = None,
-        owner: Optional[str] = None,
-    ) -> str:
-        owner_name = self.unit_name if owner == 'unit' else self.app_name
-        return self._secret_add(
-            content, owner_name, label=label, description=description, expire=expire, rotate=rotate
-        )
-
-    def _secret_add(
-        self,
-        content: Dict[str, str],
-        owner_name: str,
-        *,
-        label: Optional[str] = None,
-        description: Optional[str] = None,
-        expire: Optional[datetime.datetime] = None,
-        rotate: Optional[model.SecretRotate] = None,
-    ) -> str:
-        id = self._generate_secret_id()
-        revision = _SecretRevision(
-            revision=1,
-            content=content,
-        )
-        secret = _Secret(
-            id=id,
-            owner_name=owner_name,
-            revisions=[revision],
-            rotate_policy=rotate.value if rotate is not None else None,
-            expire_time=expire,
-            label=label,
-            description=description,
-        )
-        self._secrets.append(secret)
-        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)
-        if not self._has_secret_owner_permission(secret):
-            raise model.SecretNotFoundError(
-                f'You must own secret {secret.id!r} to perform this operation'
-            )
-
-        if relation_id not in secret.grants:
-            secret.grants[relation_id] = set()
-        remote_app_name = self._relation_app_and_units[relation_id]['app']
-        secret.grants[relation_id].add(unit or remote_app_name)
-
-    def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None) -> None:
-        secret = self._ensure_secret(id)
-        if not self._has_secret_owner_permission(secret):
-            raise RuntimeError(f'You must own secret {secret.id!r} to perform this operation')
-
-        if relation_id not in secret.grants:
-            return
-        remote_app_name = self._relation_app_and_units[relation_id]['app']
-        secret.grants[relation_id].discard(unit or remote_app_name)
-
-    def secret_remove(self, id: str, *, revision: Optional[int] = None) -> None:
-        secret = self._ensure_secret(id)
-        if not self._has_secret_owner_permission(secret):
-            raise RuntimeError(f'You must own secret {secret.id!r} to perform this operation')
-
-        if revision is not None:
-            revisions = [r for r in secret.revisions if r.revision != revision]
-            if len(revisions) == len(secret.revisions):
-                raise model.SecretNotFoundError(f'Secret {id!r} revision {revision} not found')
-            if revisions:
-                secret.revisions = revisions
-            else:
-                # Last revision removed, remove entire secret
-                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 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)
-        protocol_lit = cast(Literal['tcp', 'udp', 'icmp'], protocol)
-        self._opened_ports.add(model.Port(protocol_lit, port))
-
-    def close_port(self, protocol: str, port: Optional[int] = None):
-        self._check_protocol_and_port(protocol, port)
-        protocol_lit = cast(Literal['tcp', 'udp', 'icmp'], protocol)
-        self._opened_ports.discard(model.Port(protocol_lit, port))
-
-    def opened_ports(self) -> Set[model.Port]:
-        return set(self._opened_ports)
-
-    def _check_protocol_and_port(self, protocol: str, port: Optional[int]):
-        # Simulate the error messages we get from Juju (not that charm tests
-        # should be testing details of error messages).
-        if protocol == 'icmp':
-            if port is not None:
-                raise model.ModelError(
-                    f'ERROR protocol "{protocol}" doesn\'t support any ports; got "{port}"\n'
-                )
-        elif protocol in ['tcp', 'udp']:
-            if port is None:
-                raise model.ModelError(
-                    f'ERROR invalid port "{protocol}": '
-                    f'strconv.Atoi: parsing "{protocol}": invalid syntax\n'
-                )
-            if not (1 <= port <= 65535):
-                raise model.ModelError(
-                    f'ERROR port range bounds must be between 1 and 65535, got {port}-{port}\n'
-                )
-        else:
-            raise model.ModelError(
-                f'ERROR invalid protocol "{protocol}", expected "tcp", "udp", or "icmp"\n'
-            )
-
-    def reboot(self, now: bool = False):
-        self._reboot_count += 1
-        if not now:
-            return
-        # This should exit, reboot, and re-emit the event, but we'll need the caller
-        # to handle everything after the exit.
-        raise SystemExit()
-
-    def credential_get(self) -> model.CloudSpec:
-        if not self._cloud_spec:
-            raise model.ModelError(
-                'ERROR cloud spec is empty, set it with `Harness.set_cloud_spec()` first'
-            )
-        return self._cloud_spec
-
-
-@_copy_docstrings(pebble.ExecProcess)
-class _TestingExecProcess:
-    def __init__(
-        self,
-        command: List[str],
-        timeout: Optional[float],
-        exit_code: Optional[int],
-        stdin: Union[TextIO, BinaryIO, None],
-        stdout: Union[TextIO, BinaryIO, None],
-        stderr: Union[TextIO, BinaryIO, None],
-        is_timeout: bool,
-    ):
-        self._command = command
-        self._timeout = timeout
-        self._is_timeout = is_timeout
-        if exit_code is None and not is_timeout:
-            raise ValueError('when is_timeout is False, exit_code must not be None')
-        self._exit_code = exit_code
-        self.stdin = stdin
-        self.stdout = stdout
-        self.stderr = stderr
-
-    def wait(self):
-        if self._is_timeout:
-            raise pebble.TimeoutError(f'timed out waiting for change ({self._timeout} seconds)')
-        if self._exit_code != 0:
-            raise pebble.ExecError(self._command, cast(int, self._exit_code), None, None)
-
-    def wait_output(self) -> Tuple[AnyStr, Optional[AnyStr]]:
-        if self._is_timeout:
-            raise pebble.TimeoutError(f'timed out waiting for change ({self._timeout} seconds)')
-        out_value = self.stdout.read() if self.stdout is not None else None
-        err_value = self.stderr.read() if self.stderr is not None else None
-        if self._exit_code != 0:
-            raise pebble.ExecError[AnyStr](
-                self._command,
-                cast(int, self._exit_code),
-                cast(Union[AnyStr, None], out_value),
-                cast(Union[AnyStr, None], err_value),
-            )
-        return cast(AnyStr, out_value), cast(Union[AnyStr, None], err_value)
-
-    def send_signal(self, sig: Union[int, str]):
-        # the process is always terminated when ExecProcess is return in the simulation.
-        raise BrokenPipeError('[Errno 32] Broken pipe')
-
-
-@_copy_docstrings(pebble.Client)
-class _TestingPebbleClient:
-    """This conforms to the interface for pebble.Client but provides canned data.
-
-    DO NOT use this class directly, it is used by `Harness`_ to run interactions with Pebble.
-    `Harness`_ is responsible for maintaining the internal consistency of the values here,
-    as the only public methods of this type are for implementing Client.
-    """
-
-    def __init__(self, backend: _TestingModelBackend, container_root: pathlib.Path):
-        self._backend = _TestingModelBackend
-        self._layers: Dict[str, pebble.Layer] = {}
-        # Has a service been started/stopped?
-        self._service_status: Dict[str, pebble.ServiceStatus] = {}
-        self._root = container_root
-        self._backend = backend
-        self._exec_handlers: Dict[Tuple[str, ...], ExecHandler] = {}
-        self._notices: Dict[Tuple[str, str], pebble.Notice] = {}
-        self._last_notice_id = 0
-        self._changes: Dict[str, pebble.Change] = {}
-        self._check_infos: Dict[str, pebble.CheckInfo] = {}
-
-    def _handle_exec(self, command_prefix: Sequence[str], handler: ExecHandler):
-        prefix = tuple(command_prefix)
-        self._exec_handlers[prefix] = handler
-
-    def _check_connection(self):
-        if not self._backend._can_connect(self):
-            msg = (
-                'Cannot connect to Pebble; did you forget to call '
-                'begin_with_initial_hooks() or set_can_connect()?'
-            )
-            raise pebble.ConnectionError(msg)
-
-    def get_system_info(self) -> pebble.SystemInfo:
-        self._check_connection()
-        return pebble.SystemInfo(version='1.0.0')
-
-    def get_warnings(
-        self,
-        select: pebble.WarningState = pebble.WarningState.PENDING,
-    ) -> List['pebble.Warning']:
-        raise NotImplementedError(self.get_warnings)
-
-    def ack_warnings(self, timestamp: datetime.datetime) -> int:
-        raise NotImplementedError(self.ack_warnings)
-
-    def get_changes(
-        self,
-        select: pebble.ChangeState = pebble.ChangeState.IN_PROGRESS,
-        service: Optional[str] = None,
-    ) -> List[pebble.Change]:
-        raise NotImplementedError(self.get_changes)
-
-    def get_change(self, change_id: str) -> pebble.Change:
-        self._check_connection()
-        try:
-            return self._changes[change_id]
-        except KeyError:
-            message = f'cannot find change with id "{change_id}"'
-            raise self._api_error(404, message) from None
-
-    def abort_change(self, change_id: pebble.ChangeID) -> pebble.Change:
-        raise NotImplementedError(self.abort_change)
-
-    def autostart_services(self, timeout: float = 30.0, delay: float = 0.1):
-        self._check_connection()
-        for name, service in self._render_services().items():
-            # TODO: jam 2021-04-20 This feels awkward that Service.startup might be a string or
-            #  might be an enum. Probably should make Service.startup a property rather than an
-            #  attribute.
-            if service.startup == '':
-                startup = pebble.ServiceStartup.DISABLED
-            else:
-                startup = pebble.ServiceStartup(service.startup)
-            if startup == pebble.ServiceStartup.ENABLED:
-                self._service_status[name] = pebble.ServiceStatus.ACTIVE
-
-    def replan_services(self, timeout: float = 30.0, delay: float = 0.1):
-        return self.autostart_services(timeout, delay)
-
-    def start_services(
-        self,
-        services: List[str],
-        timeout: float = 30.0,
-        delay: float = 0.1,
-    ):
-        # A common mistake is to pass just the name of a service, rather than a list of services,
-        # so trap that so it is caught quickly.
-        if isinstance(services, str):
-            raise TypeError(f'start_services should take a list of names, not just "{services}"')
-
-        self._check_connection()
-
-        # Note: jam 2021-04-20 We don't implement ChangeID, but the default caller of this is
-        # Container.start() which currently ignores the return value
-        known_services = self._render_services()
-        # Names appear to be validated before any are activated, so do two passes
-        for name in services:
-            if name not in known_services:
-                # TODO: jam 2021-04-20 This needs a better error type
-                raise RuntimeError(f'400 Bad Request: service "{name}" does not exist')
-        for name in services:
-            self._service_status[name] = pebble.ServiceStatus.ACTIVE
-
-    def stop_services(
-        self,
-        services: List[str],
-        timeout: float = 30.0,
-        delay: float = 0.1,
-    ):
-        # handle a common mistake of passing just a name rather than a list of names
-        if isinstance(services, str):
-            raise TypeError(f'stop_services should take a list of names, not just "{services}"')
-
-        self._check_connection()
-
-        # Note: jam 2021-04-20 We don't implement ChangeID, but the default caller of this is
-        # Container.stop() which currently ignores the return value
-        known_services = self._render_services()
-        for name in services:
-            if name not in known_services:
-                # TODO: jam 2021-04-20 This needs a better error type
-                #  400 Bad Request: service "bal" does not exist
-                raise RuntimeError(f'400 Bad Request: service "{name}" does not exist')
-        for name in services:
-            self._service_status[name] = pebble.ServiceStatus.INACTIVE
-
-    def restart_services(
-        self,
-        services: List[str],
-        timeout: float = 30.0,
-        delay: float = 0.1,
-    ):
-        # handle a common mistake of passing just a name rather than a list of names
-        if isinstance(services, str):
-            raise TypeError(f'restart_services should take a list of names, not just "{services}"')
-
-        self._check_connection()
-
-        # Note: jam 2021-04-20 We don't implement ChangeID, but the default caller of this is
-        # Container.restart() which currently ignores the return value
-        known_services = self._render_services()
-        for name in services:
-            if name not in known_services:
-                # TODO: jam 2021-04-20 This needs a better error type
-                #  400 Bad Request: service "bal" does not exist
-                raise RuntimeError(f'400 Bad Request: service "{name}" does not exist')
-        for name in services:
-            self._service_status[name] = pebble.ServiceStatus.ACTIVE
-
-    def wait_change(
-        self,
-        change_id: pebble.ChangeID,
-        timeout: float = 30.0,
-        delay: float = 0.1,
-    ) -> pebble.Change:
-        raise NotImplementedError(self.wait_change)
-
-    def add_layer(
-        self,
-        label: str,
-        layer: Union[str, 'pebble.LayerDict', pebble.Layer],
-        *,
-        combine: bool = False,
-    ):
-        # I wish we could combine some of this helpful object corralling with the actual backend,
-        # rather than having to re-implement it. Maybe we could subclass
-        if not isinstance(label, str):
-            raise TypeError(f'label must be a str, not {type(label).__name__}')
-
-        if isinstance(layer, (str, dict)):
-            layer_obj = pebble.Layer(layer)
-        elif isinstance(layer, pebble.Layer):
-            layer_obj = layer
-        else:
-            raise TypeError(
-                f'layer must be str, dict, or pebble.Layer, not {type(layer).__name__}'
-            )
-
-        self._check_connection()
-
-        if label in self._layers:
-            if not combine:
-                raise RuntimeError(f'400 Bad Request: layer "{label}" already exists')
-            layer = self._layers[label]
-
-            for name, service in layer_obj.services.items():
-                # 'override' is actually single quoted in the real error, but
-                # it shouldn't be, hopefully that gets cleaned up.
-                if not service.override:
-                    raise RuntimeError(
-                        f'500 Internal Server Error: layer "{label}" must define'
-                        f'"override" for service "{name}"'
-                    )
-                if service.override not in ('merge', 'replace'):
-                    raise RuntimeError(
-                        f'500 Internal Server Error: layer "{label}" has invalid '
-                        f'"override" value on service "{name}"'
-                    )
-                elif service.override == 'replace':
-                    layer.services[name] = service
-                elif service.override == 'merge':
-                    if combine and name in layer.services:
-                        layer.services[name]._merge(service)
-                    else:
-                        layer.services[name] = service
-
-            for name, check in layer_obj.checks.items():
-                if not check.override:
-                    raise RuntimeError(
-                        f'500 Internal Server Error: layer "{label}" must define'
-                        f'"override" for check "{name}"'
-                    )
-                if check.override not in ('merge', 'replace'):
-                    raise RuntimeError(
-                        f'500 Internal Server Error: layer "{label}" has invalid '
-                        f'"override" value for check "{name}"'
-                    )
-                elif check.override == 'replace':
-                    layer.checks[name] = check
-                elif check.override == 'merge':
-                    if combine and name in layer.checks:
-                        layer.checks[name]._merge(check)
-                    else:
-                        layer.checks[name] = check
-
-            for name, log_target in layer_obj.log_targets.items():
-                if not log_target.override:
-                    raise RuntimeError(
-                        f'500 Internal Server Error: layer "{label}" must define'
-                        f'"override" for log target "{name}"'
-                    )
-                if log_target.override not in ('merge', 'replace'):
-                    raise RuntimeError(
-                        f'500 Internal Server Error: layer "{label}" has invalid '
-                        f'"override" value for log target "{name}"'
-                    )
-                elif log_target.override == 'replace':
-                    layer.log_targets[name] = log_target
-                elif log_target.override == 'merge':
-                    if combine and name in layer.log_targets:
-                        layer.log_targets[name]._merge(log_target)
-                    else:
-                        layer.log_targets[name] = log_target
-
-        else:
-            self._layers[label] = layer_obj
-
-    def _render_services(self) -> Dict[str, pebble.Service]:
-        services: Dict[str, pebble.Service] = {}
-        for key in sorted(self._layers.keys()):
-            layer = self._layers[key]
-            for name, service in layer.services.items():
-                # TODO: merge existing services https://github.com/canonical/operator/issues/1112
-                services[name] = service
-        return services
-
-    def _render_checks(self) -> Dict[str, pebble.Check]:
-        checks: Dict[str, pebble.Check] = {}
-        for key in sorted(self._layers.keys()):
-            layer = self._layers[key]
-            for name, check in layer.checks.items():
-                checks[name] = check
-        return checks
-
-    def _render_log_targets(self) -> Dict[str, pebble.LogTarget]:
-        log_targets: Dict[str, pebble.LogTarget] = {}
-        for key in sorted(self._layers.keys()):
-            layer = self._layers[key]
-            for name, log_target in layer.log_targets.items():
-                log_targets[name] = log_target
-        return log_targets
-
-    def get_plan(self) -> pebble.Plan:
-        self._check_connection()
-        plan = pebble.Plan('{}')
-        plan.services.update(self._render_services())
-        plan.checks.update(self._render_checks())
-        plan.log_targets.update(self._render_log_targets())
-        return plan
-
-    def get_services(self, names: Optional[List[str]] = None) -> List[pebble.ServiceInfo]:
-        if isinstance(names, str):
-            raise TypeError(f'start_services should take a list of names, not just "{names}"')
-
-        self._check_connection()
-        services = self._render_services()
-        infos: List[pebble.ServiceInfo] = []
-        if names is None:
-            names = sorted(services.keys())
-        for name in sorted(names):
-            try:
-                service = services[name]
-            except KeyError:
-                # in pebble, it just returns "nothing matched" if there are 0 matches,
-                # but it ignores services it doesn't recognize
-                continue
-            status = self._service_status.get(name, pebble.ServiceStatus.INACTIVE)
-            if service.startup == '':
-                startup = pebble.ServiceStartup.DISABLED
-            else:
-                startup = pebble.ServiceStartup(service.startup)
-            info = pebble.ServiceInfo(name, startup=startup, current=pebble.ServiceStatus(status))
-            infos.append(info)
-        return infos
-
-    @staticmethod
-    def _check_absolute_path(path: str):
-        if not path.startswith('/'):
-            raise pebble.PathError('generic-file-error', f'paths must be absolute, got {path!r}')
-
-    def pull(self, path: str, *, encoding: Optional[str] = 'utf-8') -> Union[BinaryIO, TextIO]:
-        self._check_connection()
-        self._check_absolute_path(path)
-        file_path = self._root / path[1:]
-        try:
-            return cast(
-                Union[BinaryIO, TextIO],
-                file_path.open('rb' if encoding is None else 'r', encoding=encoding),
-            )
-        except FileNotFoundError:
-            raise pebble.PathError(
-                'not-found', f'stat {path}: no such file or directory'
-            ) from None
-        except IsADirectoryError:
-            raise pebble.PathError(
-                'generic-file-error', f'can only read a regular file: "{path}"'
-            ) from None
-
-    def push(
-        self,
-        path: str,
-        source: 'ReadableBuffer',
-        *,
-        encoding: str = 'utf-8',
-        make_dirs: bool = False,
-        permissions: Optional[int] = None,
-        user_id: Optional[int] = None,
-        user: Optional[str] = None,
-        group_id: Optional[int] = None,
-        group: Optional[str] = None,
-    ) -> None:
-        self._check_connection()
-        if permissions is not None and not (0 <= permissions <= 0o777):
-            raise pebble.PathError(
-                'generic-file-error', f'permissions not within 0o000 to 0o777: {permissions:#o}'
-            )
-        self._check_absolute_path(path)
-        file_path = self._root / path[1:]
-        if make_dirs and not file_path.parent.exists():
-            self.make_dir(
-                os.path.dirname(path),
-                make_parents=True,
-                permissions=None,
-                user_id=user_id,
-                user=user,
-                group_id=group_id,
-                group=group,
-            )
-        permissions = permissions if permissions is not None else 0o644
-        try:
-            if isinstance(source, str):
-                file_path.write_text(source, encoding=encoding)
-            elif isinstance(source, bytes):
-                file_path.write_bytes(source)
-            else:
-                # If source is binary, open file in binary mode and ignore encoding param
-                is_binary = isinstance(source.read(0), bytes)  # type: ignore
-                open_mode = 'wb' if is_binary else 'w'
-                open_encoding = None if is_binary else encoding
-                with file_path.open(open_mode, encoding=open_encoding) as f:
-                    shutil.copyfileobj(cast(IOBase, source), cast(IOBase, f))
-            os.chmod(file_path, permissions)
-        except FileNotFoundError as e:
-            raise pebble.PathError(
-                'not-found', f'parent directory not found: {e.args[0]}'
-            ) from None
-        except NotADirectoryError:
-            raise pebble.PathError(
-                'generic-file-error', f'open {path}.~: not a directory'
-            ) from None
-
-    def list_files(
-        self, path: str, *, pattern: Optional[str] = None, itself: bool = False
-    ) -> List[pebble.FileInfo]:
-        self._check_connection()
-        self._check_absolute_path(path)
-        file_path = self._root / path[1:]
-        if not file_path.exists():
-            raise self._api_error(404, f'stat {path}: no such file or directory')
-        files = [file_path]
-        if not itself:
-            try:
-                files = [file_path / file for file in os.listdir(file_path)]
-            except NotADirectoryError:
-                pass
-
-        if pattern is not None:
-            files = [file for file in files if fnmatch.fnmatch(file.name, pattern)]
-
-        file_infos = [Container._build_fileinfo(file) for file in files]
-        for file_info in file_infos:
-            rel_path = os.path.relpath(file_info.path, start=self._root)
-            rel_path = '/' if rel_path == '.' else '/' + rel_path
-            file_info.path = rel_path
-            if rel_path == '/':
-                file_info.name = '/'
-        return file_infos
-
-    def make_dir(
-        self,
-        path: str,
-        *,
-        make_parents: bool = False,
-        permissions: Optional[int] = None,
-        user_id: Optional[int] = None,
-        user: Optional[str] = None,
-        group_id: Optional[int] = None,
-        group: Optional[str] = None,
-    ) -> None:
-        self._check_connection()
-        if permissions is not None and not (0 <= permissions <= 0o777):
-            raise pebble.PathError(
-                'generic-file-error', f'permissions not within 0o000 to 0o777: {permissions:#o}'
-            )
-        self._check_absolute_path(path)
-        dir_path = self._root / path[1:]
-        if not dir_path.parent.exists() and not make_parents:
-            raise pebble.PathError('not-found', f'parent directory not found: {path}')
-        if not dir_path.parent.exists() and make_parents:
-            self.make_dir(
-                os.path.dirname(path),
-                make_parents=True,
-                permissions=permissions,
-                user_id=user_id,
-                user=user,
-                group_id=group_id,
-                group=group,
-            )
-        try:
-            permissions = permissions if permissions else 0o755
-            dir_path.mkdir()
-            os.chmod(dir_path, permissions)
-        except FileExistsError:
-            if not make_parents:
-                raise pebble.PathError(
-                    'generic-file-error', f'mkdir {path}: file exists'
-                ) from None
-        except NotADirectoryError as e:
-            # Attempted to create a subdirectory of a file
-            raise pebble.PathError('generic-file-error', f'not a directory: {e.args[0]}') from None
-
-    def remove_path(self, path: str, *, recursive: bool = False):
-        self._check_connection()
-        self._check_absolute_path(path)
-        file_path = self._root / path[1:]
-        if not file_path.exists():
-            if recursive:
-                return
-            raise pebble.PathError('not-found', f'remove {path}: no such file or directory')
-        if file_path.is_dir():
-            if recursive:
-                shutil.rmtree(file_path)
-            else:
-                try:
-                    file_path.rmdir()
-                except OSError as e:
-                    raise pebble.PathError(
-                        'generic-file-error',
-                        'cannot remove non-empty directory without recursive=True',
-                    ) from e
-        else:
-            file_path.unlink()
-
-    def _find_exec_handler(self, command: List[str]) -> Optional[ExecHandler]:
-        for prefix_len in reversed(range(len(command) + 1)):
-            command_prefix = tuple(command[:prefix_len])
-            if command_prefix in self._exec_handlers:
-                return self._exec_handlers[command_prefix]
-        return None
-
-    def _transform_exec_handler_output(
-        self, data: Union[str, bytes], encoding: Optional[str]
-    ) -> Union[io.BytesIO, io.StringIO]:
-        if isinstance(data, bytes):
-            if encoding is None:
-                return io.BytesIO(data)
-            else:
-                return io.StringIO(data.decode(encoding=encoding))
-        else:
-            if encoding is None:
-                raise ValueError(
-                    f'exec handler must return bytes if encoding is None,'
-                    f'not {data.__class__.__name__}'
-                )
-            else:
-                return io.StringIO(typing.cast(str, data))
-
-    def exec(
-        self,
-        command: List[str],
-        *,
-        service_context: Optional[str] = None,
-        environment: Optional[Dict[str, str]] = None,
-        working_dir: Optional[str] = None,
-        timeout: Optional[float] = None,
-        user_id: Optional[int] = None,
-        user: Optional[str] = None,
-        group_id: Optional[int] = None,
-        group: Optional[str] = None,
-        stdin: Optional[Union[str, bytes, TextIO, BinaryIO]] = None,
-        stdout: Optional[Union[TextIO, BinaryIO]] = None,
-        stderr: Optional[Union[TextIO, BinaryIO]] = None,
-        encoding: Optional[str] = 'utf-8',
-        combine_stderr: bool = False,
-    ) -> ExecProcess[Any]:
-        self._check_connection()
-        handler = self._find_exec_handler(command)
-        if handler is None:
-            message = 'execution handler not found, please register one using Harness.handle_exec'
-            raise self._api_error(500, message)
-        environment = {} if environment is None else environment
-        if service_context is not None:
-            plan = self.get_plan()
-            if service_context not in plan.services:
-                message = f'context service "{service_context}" not found'
-                raise self._api_error(500, message)
-            service = plan.services[service_context]
-            environment = {**service.environment, **environment}
-            working_dir = service.working_dir if working_dir is None else working_dir
-            user = service.user if user is None else user
-            user_id = service.user_id if user_id is None else user_id
-            group = service.group if group is None else group
-            group_id = service.group_id if group_id is None else group_id
-
-        if hasattr(stdin, 'read'):
-            stdin = stdin.read()  # type: ignore
-
-        exec_args = ExecArgs(
-            command=command,
-            environment=environment,
-            working_dir=working_dir,
-            timeout=timeout,
-            user_id=user_id,
-            user=user,
-            group_id=group_id,
-            group=group,
-            stdin=cast(Union[str, bytes, None], stdin),
-            encoding=encoding,
-            combine_stderr=combine_stderr,
-        )
-        proc_stdin = self._transform_exec_handler_output(b'', encoding)
-        if stdin is not None:
-            proc_stdin = None
-        proc_stdout = self._transform_exec_handler_output(b'', encoding)
-        proc_stderr = self._transform_exec_handler_output(b'', encoding)
-        try:
-            result = handler(exec_args)
-        except TimeoutError:
-            if timeout is not None:
-                exec_process = _TestingExecProcess(
-                    command=command,
-                    timeout=timeout,
-                    exit_code=None,
-                    stdin=proc_stdin,
-                    stdout=proc_stdout,
-                    stderr=proc_stderr,
-                    is_timeout=True,
-                )
-                return cast(pebble.ExecProcess[Any], exec_process)
-            else:
-                raise RuntimeError(
-                    'a TimeoutError occurred in the execution handler, '
-                    'but no timeout value was provided in the execution arguments.'
-                ) from None
-        if result is None:
-            exit_code = 0
-            proc_stdout = self._transform_exec_handler_output(b'', encoding)
-            proc_stderr = self._transform_exec_handler_output(b'', encoding)
-        elif isinstance(result, ExecResult):
-            exit_code = result.exit_code
-            proc_stdout = self._transform_exec_handler_output(result.stdout, encoding)
-            proc_stderr = self._transform_exec_handler_output(result.stderr, encoding)
-        else:
-            raise TypeError(f'execution handler returned an unexpected type: {type(result)!r}.')
-        if combine_stderr and proc_stderr.getvalue():
-            raise ValueError(
-                'execution handler returned a non-empty stderr '
-                'even though combine_stderr is enabled.'
-            )
-        if stdout is not None:
-            shutil.copyfileobj(cast(io.IOBase, proc_stdout), cast(io.IOBase, stdout))
-            proc_stdout = None
-        if stderr is not None:
-            shutil.copyfileobj(cast(io.IOBase, proc_stderr), cast(io.IOBase, stderr))
-            proc_stderr = None
-        exec_process = _TestingExecProcess(
-            command=command,
-            timeout=timeout,
-            exit_code=exit_code,
-            stdin=proc_stdin,
-            stdout=proc_stdout,
-            stderr=proc_stderr,
-            is_timeout=False,
-        )
-        return cast(pebble.ExecProcess[Any], exec_process)
-
-    def send_signal(self, sig: Union[int, str], service_names: Iterable[str]):
-        if not service_names:
-            raise TypeError('send_signal expected at least 1 service name, got 0')
-        self._check_connection()
-
-        # Convert signal to str
-        if isinstance(sig, int):
-            sig = signal.Signals(sig).name
-
-        # pebble first validates the service name, and then the signal name
-
-        plan = self.get_plan()
-        for service in service_names:
-            if service not in plan.services or not self.get_services([service])[0].is_running():
-                # conform with the real pebble api
-                message = f'cannot send signal to "{service}": service is not running'
-                raise self._api_error(500, message)
-
-        # Check if signal name is valid
-        try:
-            signal.Signals[sig]
-        except KeyError:
-            # conform with the real pebble api
-            first_service = next(iter(service_names))
-            message = f'cannot send signal to "{first_service}": invalid signal name "{sig}"'
-            raise self._api_error(500, message) from None
-
-    def get_checks(
-        self, level: Optional[pebble.CheckLevel] = None, names: Optional[Iterable[str]] = None
-    ) -> List[pebble.CheckInfo]:
-        if names is not None:
-            names = frozenset(names)
-        return [
-            info
-            for info in self._check_infos.values()
-            if (level is None or level == info.level) and (names is None or info.name in names)
-        ]
-
-    def notify(
-        self,
-        type: pebble.NoticeType,
-        key: str,
-        *,
-        data: Optional[Dict[str, str]] = None,
-        repeat_after: Optional[datetime.timedelta] = None,
-    ) -> str:
-        notice_id, _ = self._notify(type, key, data=data, repeat_after=repeat_after)
-        return notice_id
-
-    def _notify(
-        self,
-        type: pebble.NoticeType,
-        key: str,
-        *,
-        data: Optional[Dict[str, str]] = None,
-        repeat_after: Optional[datetime.timedelta] = None,
-    ) -> Tuple[str, bool]:
-        """Record an occurrence of a notice with the specified details.
-
-        Return a tuple of (notice_id, new_or_repeated).
-        """
-        # The shape of the code below is taken from State.AddNotice in Pebble.
-        now = datetime.datetime.now(tz=datetime.timezone.utc)
-
-        new_or_repeated = False
-        unique_key = (type.value, key)
-        notice = self._notices.get(unique_key)
-        if notice is None:
-            # First occurrence of this notice uid+type+key
-            self._last_notice_id += 1
-            notice = pebble.Notice(
-                id=str(self._last_notice_id),
-                user_id=0,  # Charm should always be able to read pebble_notify notices.
-                type=type,
-                key=key,
-                first_occurred=now,
-                last_occurred=now,
-                last_repeated=now,
-                expire_after=datetime.timedelta(days=7),
-                occurrences=1,
-                last_data=data or {},
-                repeat_after=repeat_after,
-            )
-            self._notices[unique_key] = notice
-            new_or_repeated = True
-        else:
-            # Additional occurrence, update existing notice
-            last_repeated = notice.last_repeated
-            if repeat_after is None or now > notice.last_repeated + repeat_after:
-                # Update last repeated time if repeat-after time has elapsed (or is None)
-                last_repeated = now
-                new_or_repeated = True
-            notice = dataclasses.replace(
-                notice,
-                last_occurred=now,
-                last_repeated=last_repeated,
-                occurrences=notice.occurrences + 1,
-                last_data=data or {},
-                repeat_after=repeat_after,
-            )
-            self._notices[unique_key] = notice
-
-        return notice.id, new_or_repeated
-
-    def _api_error(self, code: int, message: str) -> pebble.APIError:
-        status = http.HTTPStatus(code).phrase
-        body = {
-            'type': 'error',
-            'status-code': code,
-            'status': status,
-            'result': {'message': message},
-        }
-        return pebble.APIError(body, code, status, message)
-
-    def get_notice(self, id: str) -> pebble.Notice:
-        for notice in self._notices.values():
-            if notice.id == id:
-                return notice
-        raise self._api_error(404, f'cannot find notice with ID "{id}"')
-
-    def get_notices(
-        self,
-        *,
-        users: Optional[pebble.NoticesUsers] = None,
-        user_id: Optional[int] = None,
-        types: Optional[Iterable[Union[pebble.NoticeType, str]]] = None,
-        keys: Optional[Iterable[str]] = None,
-    ) -> List[pebble.Notice]:
-        # Similar logic as api_notices.go:v1GetNotices in Pebble.
-
-        filter_user_id = 0  # default is to filter by request UID (root)
-        if user_id is not None:
-            filter_user_id = user_id
-        if users is not None:
-            if user_id is not None:
-                raise self._api_error(400, 'cannot use both "users" and "user_id"')
-            filter_user_id = None
-
-        if types is not None:
-            types = {(t.value if isinstance(t, pebble.NoticeType) else t) for t in types}
-        if keys is not None:
-            keys = set(keys)
-
-        notices = [
-            notice
-            for notice in self._notices.values()
-            if self._notice_matches(notice, filter_user_id, types, keys)
-        ]
-        notices.sort(key=lambda notice: notice.last_repeated)
-        return notices
-
-    @staticmethod
-    def _notice_matches(
-        notice: pebble.Notice,
-        user_id: Optional[int] = None,
-        types: Optional[Set[str]] = None,
-        keys: Optional[Set[str]] = None,
-    ) -> bool:
-        # Same logic as NoticeFilter.matches in Pebble.
-        # For example: if user_id filter is set and it doesn't match, return False.
-        if user_id is not None and not (notice.user_id is None or user_id == notice.user_id):
-            return False
-        if types is not None and notice.type not in types:
-            return False
-        if keys is not None and notice.key not in keys:
-            return False
-        return True
+# The Harness testing framework.
+_ = ActionFailed
+_ = ActionOutput
+_ = AppUnitOrName
+_ = CharmType
+_ = ExecArgs
+_ = ExecHandler
+_ = ExecResult
+_ = Harness
+_ = ReadableBuffer
+_ = YAMLStringOrFile
+
+# Names exposed for backwards compatibility
+_ = CharmBase
+_ = CharmMeta
+_ = Container
+_ = ExecProcess
+_ = RelationNotFoundError
+_ = RelationRole
+_ = charm
+_ = framework
+_ = model
+_ = pebble
+_ = storage


Test patch:
diff --git a/test/test_charm.py b/test/test_charm.py
index b6879b151..0939fbb6b 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -22,7 +22,7 @@
 
 import ops
 import ops.charm
-from ops.model import ModelError
+from ops.model import ModelError, StatusName
 
 from .test_helpers import FakeScript, create_framework
 
@@ -956,22 +956,20 @@ def _on_collect_status(self, event: ops.CollectStatusEvent):
 @pytest.mark.parametrize(
     'statuses,expected',
     [
-        (['blocked', 'error'], 'error'),
         (['waiting', 'blocked'], 'blocked'),
         (['waiting', 'maintenance'], 'maintenance'),
         (['active', 'waiting'], 'waiting'),
         (['active', 'unknown'], 'active'),
-        (['unknown'], 'unknown'),
     ],
 )
-def test_collect_status_priority(
+def test_collect_status_priority_valid(
     request: pytest.FixtureRequest,
     fake_script: FakeScript,
-    statuses: typing.List[str],
+    statuses: typing.List[StatusName],
     expected: str,
 ):
     class MyCharm(ops.CharmBase):
-        def __init__(self, framework: ops.Framework, statuses: typing.List[str]):
+        def __init__(self, framework: ops.Framework, statuses: typing.List[StatusName]):
             super().__init__(framework)
             self.framework.observe(self.on.collect_app_status, self._on_collect_status)
             self.statuses = statuses
@@ -991,6 +989,36 @@ def _on_collect_status(self, event: ops.CollectStatusEvent):
     assert status_set_calls == [['status-set', '--application=True', expected, '']]
 
 
+@pytest.mark.parametrize(
+    'statuses',
+    [
+        ['blocked', 'error'],
+        ['unknown'],
+    ],
+)
+def test_collect_status_priority_invalid(
+    request: pytest.FixtureRequest,
+    fake_script: FakeScript,
+    statuses: typing.List[StatusName],
+):
+    class MyCharm(ops.CharmBase):
+        def __init__(self, framework: ops.Framework, statuses: typing.List[StatusName]):
+            super().__init__(framework)
+            self.framework.observe(self.on.collect_app_status, self._on_collect_status)
+            self.statuses = statuses
+
+        def _on_collect_status(self, event: ops.CollectStatusEvent):
+            for status in self.statuses:
+                event.add_status(ops.StatusBase.from_name(status, ''))
+
+    fake_script.write('is-leader', 'echo true')
+
+    framework = create_framework(request)
+    charm = MyCharm(framework, statuses=statuses)
+    with pytest.raises(ModelError):
+        ops.charm._evaluate_status(charm)
+
+
 def test_meta_links():
     # Each type of link can be a single item.
     meta = ops.CharmMeta.from_yaml("""
diff --git a/test/test_model.py b/test/test_model.py
index f8af18404..0eadd4d2c 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -24,7 +24,7 @@
 import unittest
 from collections import OrderedDict
 from textwrap import dedent
-from unittest.mock import ANY, MagicMock, patch
+from unittest import mock
 
 import pytest
 
@@ -823,8 +823,8 @@ def test_base_status_instance_raises(self):
         class NoNameStatus(ops.StatusBase):
             pass
 
-        with pytest.raises(AttributeError):
-            ops.StatusBase.register_status(NoNameStatus)  # type: ignore
+        with pytest.raises(TypeError):
+            ops.StatusBase.register(NoNameStatus)
 
     def test_status_repr(self):
         test_cases = {
@@ -1114,9 +1114,9 @@ def test_run_error(self, fake_script: FakeScript):
         assert str(excinfo.value) == 'ERROR cannot get status\n'
         assert excinfo.value.args[0] == 'ERROR cannot get status\n'
 
-    @patch('grp.getgrgid')
-    @patch('pwd.getpwuid')
-    def test_push_path_unnamed(self, getpwuid: MagicMock, getgrgid: MagicMock):
+    @mock.patch('grp.getgrgid')
+    @mock.patch('pwd.getpwuid')
+    def test_push_path_unnamed(self, getpwuid: mock.MagicMock, getgrgid: mock.MagicMock):
         getpwuid.side_effect = KeyError
         getgrgid.side_effect = KeyError
         harness = ops.testing.Harness(
@@ -2878,34 +2878,28 @@ def test_status_is_app_forced_kwargs(self, fake_script: FakeScript):
                 case()
 
     def test_local_set_invalid_status(self, fake_script: FakeScript):
-        # juju returns exit code 1 if you ask to set status to 'unknown' or 'error'
+        # ops will directly raise InvalidStatusError if you try to set status to unknown or error
         meta = ops.CharmMeta.from_yaml("""
             name: myapp
         """)
         model = ops.Model(meta, self.backend)
-        fake_script.write('status-set', 'exit 1')
         fake_script.write('is-leader', 'echo true')
 
-        with pytest.raises(ops.ModelError):
+        with pytest.raises(ops.InvalidStatusError):
             model.unit.status = ops.UnknownStatus()
-        with pytest.raises(ops.ModelError):
+        with pytest.raises(ops.InvalidStatusError):
             model.unit.status = ops.ErrorStatus()
 
-        assert fake_script.calls(True) == [
-            ['status-set', '--application=False', 'unknown', ''],
-            ['status-set', '--application=False', 'error', ''],
-        ]
+        assert fake_script.calls(True) == []
 
-        with pytest.raises(ops.ModelError):
+        with pytest.raises(ops.InvalidStatusError):
             model.app.status = ops.UnknownStatus()
-        with pytest.raises(ops.ModelError):
+        with pytest.raises(ops.InvalidStatusError):
             model.app.status = ops.ErrorStatus()
 
         # A leadership check is needed for application status.
         assert fake_script.calls(True) == [
             ['is-leader', '--format=json'],
-            ['status-set', '--application=True', 'unknown', ''],
-            ['status-set', '--application=True', 'error', ''],
         ]
 
     @pytest.mark.parametrize('name', ['active', 'waiting', 'blocked', 'maintenance', 'error'])
@@ -2949,9 +2943,20 @@ def test_local_get_status(self, fake_script: FakeScript, name: str):
         assert model.app.status.message == 'bar'
 
     def test_status_set_is_app_not_bool_raises(self):
-        for is_app_v in [None, 1, 2.0, 'a', b'beef', object]:
+        for is_app_v in [None, 1, 2.0, 'a', b'beef', object()]:
             with pytest.raises(TypeError):
-                self.backend.status_set(ops.ActiveStatus, is_app=is_app_v)  # type: ignore
+                self.backend.status_set(
+                    'active',
+                    is_app=is_app_v,  # type: ignore[assignment]
+                )
+
+    def test_status_set_message_not_str_raises(self):
+        for message in [None, 1, 2.0, True, b'beef', object()]:
+            with pytest.raises(TypeError):
+                self.backend.status_set(
+                    'active',
+                    message=message,  # type: ignore[assignment]
+                )
 
     def test_storage_tool_errors(self, fake_script: FakeScript):
         fake_script.write('storage-list', 'echo fooerror >&2 ; exit 1')
@@ -3364,7 +3369,9 @@ def test_app_add_secret_simple(self, fake_script: FakeScript, model: ops.Model):
         assert secret.id == 'secret:123'
         assert secret.label is None
 
-        assert fake_script.calls(clear=True) == [['secret-add', '--owner', 'application', ANY]]
+        assert fake_script.calls(clear=True) == [
+            ['secret-add', '--owner', 'application', mock.ANY]
+        ]
         assert fake_script.secrets() == {'foo': 'x'}
 
     def test_app_add_secret_args(self, fake_script: FakeScript, model: ops.Model):
@@ -3395,8 +3402,8 @@ def test_app_add_secret_args(self, fake_script: FakeScript, model: ops.Model):
                 'hourly',
                 '--owner',
                 'application',
-                ANY,
-                ANY,
+                mock.ANY,
+                mock.ANY,
             ]
         ]
         assert fake_script.secrets() == {'foo': 'x', 'bar': 'y'}
@@ -3409,7 +3416,7 @@ def test_unit_add_secret_simple(self, fake_script: FakeScript, model: ops.Model)
         assert secret.id == 'secret:345'
         assert secret.label is None
 
-        assert fake_script.calls(clear=True) == [['secret-add', '--owner', 'unit', ANY]]
+        assert fake_script.calls(clear=True) == [['secret-add', '--owner', 'unit', mock.ANY]]
         assert fake_script.secrets() == {'foo': 'x'}
 
     def test_unit_add_secret_args(self, fake_script: FakeScript, model: ops.Model):
@@ -3440,8 +3447,8 @@ def test_unit_add_secret_args(self, fake_script: FakeScript, model: ops.Model):
                 'yearly',
                 '--owner',
                 'unit',
-                ANY,
-                ANY,
+                mock.ANY,
+                mock.ANY,
             ]
         ]
         assert fake_script.secrets() == {'foo': 'w', 'bar': 'z'}
@@ -3771,9 +3778,9 @@ def test_set_content(self, model: ops.Model, fake_script: FakeScript):
             secret.set_content({'s': 't'})  # ensure it validates content (key too short)
 
         assert fake_script.calls(clear=True) == [
-            ['secret-set', f'secret://{model._backend.model_uuid}/x', ANY],
+            ['secret-set', f'secret://{model._backend.model_uuid}/x', mock.ANY],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-set', f'secret://{model._backend.model_uuid}/z', ANY],
+            ['secret-set', f'secret://{model._backend.model_uuid}/z', mock.ANY],
         ]
         assert fake_script.secrets() == {'foo': 'bar', 'bar': 'foo'}
 
diff --git a/test/test_testing.py b/test/test_testing.py
index e9e34f34d..4c88f232b 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -39,10 +39,11 @@
 import ops
 import ops.testing
 from ops import pebble
+from ops._private.harness import _TestingPebbleClient
 from ops.jujuversion import JujuVersion
 from ops.model import _ModelBackend
 from ops.pebble import FileType
-from ops.testing import ExecResult, _TestingPebbleClient
+from ops.testing import ExecResult
 
 is_linux = platform.system() == 'Linux'
 
@@ -6888,7 +6889,7 @@ def get_change(_: ops.pebble.Client, change_id: str):
                 'spawn-time': '2021-02-10T04:36:22.118970777Z',
             })
 
-        monkeypatch.setattr(ops.testing._TestingPebbleClient, 'get_change', get_change)
+        monkeypatch.setattr(_TestingPebbleClient, 'get_change', get_change)
         harness.pebble_notify(
             'foo',
             '123',