diff --git a/.github/workflows/db-charm-tests.yaml b/.github/workflows/db-charm-tests.yaml index bfb20f2ec..0ddfa2343 100644 --- a/.github/workflows/db-charm-tests.yaml +++ b/.github/workflows/db-charm-tests.yaml @@ -18,8 +18,10 @@ jobs: commit: 01cfd3e268ecd5024ded6bb6aede43ff85417b78 # 2024-09-11T16:07:20Z - charm-repo: canonical/postgresql-k8s-operator commit: f203e4d9d3ab9856b5dc0786217fce8428384e90 # 2024-09-16T12:00:58Z - - charm-repo: canonical/mysql-operator - commit: 4ff2dc867a1586d43a93d0017cc648d591fc9886 # 2024-09-13T15:38:12Z + # 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: 2009919583eddad22656b9b4ebe95adbcd518c0f # rev199 rev198 2024-09-16T18:14:16Z steps: diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml index d6ce7c904..af1879afb 100644 --- a/.github/workflows/observability-charm-tests.yaml +++ b/.github/workflows/observability-charm-tests.yaml @@ -16,11 +16,15 @@ jobs: matrix: include: - charm-repo: canonical/alertmanager-k8s-operator - commit: 7518ec8343941ca91248614fd8f2d50fdd9e068c # rev135 2024-09-06T12:10:02Z - - charm-repo: canonical/prometheus-k8s-operator - commit: 1bd9e7fd96a9f5a91443e5932667c58a92c1ce5e # 2024-09-03T14:57:42Z - - charm-repo: canonical/grafana-k8s-operator - commit: 1c80f746f8edeae6fd23ddf31eed45f5b88c06b4 # rev118 2024-08-12T14:02:32Z + commit: 4b2f567c43bc67e9d1fe24f018ba186dcd0396ed # 2024-08-28T11:32:28Z + # 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/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/_private/harness.py b/ops/_private/harness.py new file mode 100644 index 000000000..dcabdf346 --- /dev/null +++ b/ops/_private/harness.py @@ -0,0 +1,3836 @@ +# 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, _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 ``/charmcraft.yaml`` first, then + ``/metadata.yaml`` if charmcraft.yaml does not include metadata, + and ``/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 ``/charmcraft.yaml`` first, then + ``/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]/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 `. + 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 + (``/0``) to the relation and trigger + :class:`RelationJoinedEvent `. Then update + the application data if `app_data` is provided and the unit data if + `unit_data` is provided, triggering + :class:`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}/, ' + 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 ` or + :class:`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 `. + """ + 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 ` 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 ` and + :attr:`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 `. + """ + 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 `. + """ + 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 ` + 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 "
/24" if address is an + IPv4 address, or "
/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 `_ 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": {"": { }, ... } + # : 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//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[a-z0-9-]+)/)?(?P[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/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 ``/charmcraft.yaml`` first, then - ``/metadata.yaml`` if charmcraft.yaml does not include metadata, - and ``/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 ``/charmcraft.yaml`` first, then - ``/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]/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 `. - 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 - (``/0``) to the relation and trigger - :class:`RelationJoinedEvent `. Then update - the application data if `app_data` is provided and the unit data if - `unit_data` is provided, triggering - :class:`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}/, ' - 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 ` or - :class:`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 `. - """ - 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 ` 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 ` and - :attr:`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 `. - """ - 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 `. - """ - 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 ` - 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 "
/24" if address is an - IPv4 address, or "
/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 `_ 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": {"": { }, ... } - # : 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//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[a-z0-9-]+)/)?(?P[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 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',