diff --git a/README.md b/README.md index 97f0c5eb..e6e111d2 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) ``` -## Containers +# Containers When testing a kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. @@ -586,7 +586,7 @@ need to associate the container with the event is that the Framework uses an env pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. -### Container filesystem post-mortem +## Container filesystem post-mortem If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api. ```python @@ -623,7 +623,7 @@ def test_pebble_push(): assert cfg_file.read_text() == "TEST" ``` -### `Container.exec` mocks +## `Container.exec` mocks `container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the @@ -671,6 +671,77 @@ def test_pebble_exec(): ) ``` +# Storage + +If your charm defines `storage` in its metadata, you can use `scenario.state.Storage` to instruct Scenario to make (mocked) filesystem storage available to the charm at runtime. + +Using the same `get_filesystem` API as `Container`, you can access the tempdir used by Scenario to mock the filesystem root before and after the scenario runs. + +```python +from scenario import Storage, Context, State +# some charm with a 'foo' filesystem-type storage defined in metadata.yaml +ctx = Context(MyCharm) +storage = Storage("foo") +# setup storage with some content +(storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") + +with ctx.manager("update-status", State(storage=[storage])) as mgr: + foo = mgr.charm.model.storages["foo"][0] + loc = foo.location + path = loc / "myfile.txt" + assert path.exists() + assert path.read_text() == "helloworld" + + myfile = loc / "path.py" + myfile.write_text("helloworlds") + +# post-mortem: inspect fs contents. +assert ( + storage.get_filesystem(ctx) / "path.py" +).read_text() == "helloworlds" +``` + +Note that State only wants to know about **attached** storages. A storage which is not attached to the charm can simply be omitted from State and the charm will be none the wiser. + +## Storage-add + +If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API. + +```python +# in MyCharm._on_foo: +# the charm requests two new "foo" storage instances to be provisioned +self.model.storages.request("foo", 2) +``` + +From test code, you can inspect that: + +```python +from scenario import Context, State + +ctx = Context(MyCharm) +ctx.run('some-event-that-will-cause_on_foo-to-be-called', State()) + +# the charm has requested two 'foo' storages to be provisioned +assert ctx.requested_storages['foo'] == 2 +``` + +Requesting storages has no other consequence in Scenario. In real life, this request will trigger Juju to provision the storage and execute the charm again with `foo-storage-attached`. +So a natural follow-up Scenario test suite for this case would be: + +```python +from scenario import Context, State, Storage + +ctx = Context(MyCharm) +foo_0 = Storage('foo') +# the charm is notified that one of the storages it has requested is ready +ctx.run(foo_0.attached_event, State(storage=[foo_0])) + +foo_1 = Storage('foo') +# the charm is notified that the other storage is also ready +ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1])) +``` + + # Ports Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened-ports` hook tools to manage the ports opened on the host vm/container. Using the `State.opened_ports` api, you can: diff --git a/pyproject.toml b/pyproject.toml index 9ac8cf8b..5001306a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.3.1" +version = "5.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/__init__.py b/scenario/__init__.py index 82b89ad6..f16a6791 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -21,6 +21,7 @@ Secret, State, StateValidationError, + Storage, StoredState, SubordinateRelation, deferred, @@ -45,6 +46,7 @@ "BindAddress", "Network", "Port", + "Storage", "StoredState", "State", "DeferredEvent", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 52d62649..9a0ffdd0 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -66,6 +66,7 @@ def check_consistency( check_config_consistency, check_event_consistency, check_secrets_consistency, + check_storages_consistency, check_relation_consistency, ): results = check( @@ -123,6 +124,9 @@ def check_event_consistency( if event._is_action_event: _check_action_event(charm_spec, event, errors, warnings) + if event._is_storage_event: + _check_storage_event(charm_spec, event, errors, warnings) + return Results(errors, warnings) @@ -190,6 +194,30 @@ def _check_action_event( _check_action_param_types(charm_spec, action, errors, warnings) +def _check_storage_event( + charm_spec: _CharmSpec, + event: "Event", + errors: List[str], + warnings: List[str], # noqa: U100 +): + storage = event.storage + if not storage: + errors.append( + "cannot construct a storage event without the Storage instance. " + "Please pass one.", + ) + elif not event.name.startswith(normalize_name(storage.name)): + errors.append( + f"storage event should start with storage name. {event.name} does " + f"not start with {storage.name}.", + ) + elif storage.name not in charm_spec.meta["storage"]: + errors.append( + f"storage event {event.name} refers to storage {storage.name} " + f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.", + ) + + def _check_action_param_types( charm_spec: _CharmSpec, action: Action, @@ -236,6 +264,37 @@ def _check_action_param_types( ) +def check_storages_consistency( + *, + state: "State", + charm_spec: "_CharmSpec", + **_kwargs, # noqa: U101 +) -> Results: + """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml).""" + state_storage = state.storage + meta_storage = (charm_spec.meta or {}).get("storage", {}) + errors = [] + + if missing := {s.name for s in state.storage}.difference( + set(meta_storage.keys()), + ): + errors.append( + f"some storages passed to State were not defined in metadata.yaml: {missing}", + ) + + seen = [] + for s in state_storage: + tag = (s.name, s.index) + if tag in seen: + errors.append( + f"duplicate storage in State: storage {s.name} with index {s.index} " + f"occurs multiple times in State.storage.", + ) + seen.append(tag) + + return Results(errors, []) + + def check_config_consistency( *, state: "State", diff --git a/scenario/context.py b/scenario/context.py index ad985974..5450205e 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -246,6 +246,7 @@ def __init__( self.unit_status_history: List["_EntityStatus"] = [] self.workload_version_history: List[str] = [] self.emitted_events: List[EventBase] = [] + self.requested_storages: Dict[str, int] = {} # set by Runtime.exec() in self._run() self._output_state: Optional["State"] = None @@ -263,6 +264,13 @@ def _get_container_root(self, container_name: str): """Get the path to a tempdir where this container's simulated root will live.""" return Path(self._tmp.name) / "containers" / container_name + def _get_storage_root(self, name: str, index: int) -> Path: + """Get the path to a tempdir where this storage's simulated root will live.""" + storage_root = Path(self._tmp.name) / "storages" / f"{name}-{index}" + # in the case of _get_container_root, _MockPebbleClient will ensure the dir exists. + storage_root.mkdir(parents=True, exist_ok=True) + return storage_root + def clear(self): """Cleanup side effects histories.""" self.juju_log = [] @@ -270,6 +278,7 @@ def clear(self): self.unit_status_history = [] self.workload_version_history = [] self.emitted_events = [] + self.requested_storages = {} self._action_logs = [] self._action_results = None self._action_failure = "" diff --git a/scenario/mocking.py b/scenario/mocking.py index 16ae0893..fe0f0a30 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -6,10 +6,11 @@ import shutil from io import StringIO from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union from ops import pebble from ops.model import ( + ModelError, SecretInfo, SecretRotate, _format_action_result_dict, @@ -19,7 +20,7 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger -from scenario.state import JujuLogLine, Mount, PeerRelation, Port +from scenario.state import JujuLogLine, Mount, PeerRelation, Port, Storage if TYPE_CHECKING: from scenario.context import Context @@ -382,21 +383,47 @@ def action_get(self): ) return action.params - # TODO: - def storage_add(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("storage_add") + def storage_add(self, name: str, count: int = 1): + if "/" in name: + raise ModelError('storage name cannot contain "/"') - def resource_get(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("resource_get") + self._context.requested_storages[name] = count - def storage_list(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("storage_list") + def storage_list(self, name: str) -> List[int]: + return [ + storage.index for storage in self._state.storage if storage.name == name + ] - def storage_get(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("storage_get") + def storage_get(self, storage_name_id: str, attribute: str) -> str: + if attribute != "location": + raise NotImplementedError( + f"storage-get not implemented for attribute={attribute}", + ) - def planned_units(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("planned_units") + name, index = storage_name_id.split("/") + index = int(index) + storages: List[Storage] = [ + s for s in self._state.storage if s.name == name and s.index == index + ] + if not storages: + raise RuntimeError(f"Storage with name={name} and index={index} not found.") + if len(storages) > 1: + # should not really happen: sanity check. + raise RuntimeError( + f"Multiple Storage instances with name={name} and index={index} found. " + f"Inconsistent state.", + ) + + storage = storages[0] + fs_path = storage.get_filesystem(self._context) + return str(fs_path) + + def planned_units(self) -> int: + return self._state.planned_units + + # TODO: + def resource_get(self, *args, **kwargs): # noqa: U100 + raise NotImplementedError("resource_get") class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/runtime.py b/scenario/runtime.py index 0de7b991..dd64c416 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -253,6 +253,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) + if storage := event.storage: + env.update({"JUJU_STORAGE_ID": f"{storage.name}/{storage.index}"}) + if secret := event.secret: env.update( { diff --git a/scenario/state.py b/scenario/state.py index 69d7cb8f..782f8bd8 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -646,7 +646,7 @@ def __eq__(self, other): "Comparing Status with Tuples is deprecated and will be removed soon.", ) return (self.name, self.message) == other - if isinstance(other, StatusBase): + if isinstance(other, (StatusBase, _EntityStatus)): return (self.name, self.message) == (other.name, other.message) logger.warning( f"Comparing Status with {other} is not stable and will be forbidden soon." @@ -659,12 +659,21 @@ def __iter__(self): def __repr__(self): status_type_name = self.name.title() + "Status" + if self.name == "unknown": + return f"{status_type_name}()" return f"{status_type_name}('{self.message}')" def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: """Convert StatusBase to _EntityStatus.""" - return _EntityStatus(obj.name, obj.message) + statusbase_subclass = type(StatusBase.from_name(obj.name, obj.message)) + + class _MyClass(_EntityStatus, statusbase_subclass): + # Custom type inheriting from a specific StatusBase subclass to support instance checks: + # isinstance(state.unit_status, ops.ActiveStatus) + pass + + return _MyClass(obj.name, obj.message) @dataclasses.dataclass(frozen=True) @@ -709,6 +718,52 @@ def __post_init__(self): ) +_next_storage_index_counter = 0 # storage indices start at 0 + + +def next_storage_index(update=True): + """Get the index (used to be called ID) the next Storage to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ + global _next_storage_index_counter + cur = _next_storage_index_counter + if update: + _next_storage_index_counter += 1 + return cur + + +@dataclasses.dataclass(frozen=True) +class Storage(_DCBase): + """Represents an (attached!) storage made available to the charm container.""" + + name: str + + index: int = dataclasses.field(default_factory=next_storage_index) + # Every new Storage instance gets a new one, if there's trouble, override. + + def get_filesystem(self, ctx: "Context") -> Path: + """Simulated filesystem root in this context.""" + return ctx._get_storage_root(self.name, self.index) + + @property + def attached_event(self) -> "Event": + """Sugar to generate a -storage-attached event.""" + return Event( + path=normalize_name(self.name + "-storage-attached"), + storage=self, + ) + + @property + def detached_event(self) -> "Event": + """Sugar to generate a -storage-detached event.""" + return Event( + path=normalize_name(self.name + "-storage-detached"), + storage=self, + ) + + @dataclasses.dataclass(frozen=True) class State(_DCBase): """Represents the juju-owned portion of a unit's state. @@ -721,30 +776,48 @@ class State(_DCBase): config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( default_factory=dict, ) + """The present configuration of this charm.""" relations: List["AnyRelation"] = dataclasses.field(default_factory=list) + """All relations that currently exist for this charm.""" networks: List[Network] = dataclasses.field(default_factory=list) + """All networks currently provisioned for this charm.""" containers: List[Container] = dataclasses.field(default_factory=list) + """All containers (whether they can connect or not) that this charm is aware of.""" + storage: List[Storage] = dataclasses.field(default_factory=list) + """All ATTACHED storage instances for this charm. + If a storage is not attached, omit it from this listing.""" # we don't use sets to make json serialization easier opened_ports: List[Port] = dataclasses.field(default_factory=list) + """Ports opened by juju on this charm.""" leader: bool = False + """Whether this charm has leadership.""" model: Model = Model() + """The model this charm lives in.""" secrets: List[Secret] = dataclasses.field(default_factory=list) + """The secrets this charm has access to (as an owner, or as a grantee).""" + planned_units: int = 1 + """Number of non-dying planned units that are expected to be running this application. + Use with caution.""" unit_id: int = 0 + """ID of the unit hosting this charm.""" # represents the OF's event queue. These events will be emitted before the event being # dispatched, and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) + """Events that have been deferred on this charm by some previous execution.""" stored_state: List["StoredState"] = dataclasses.field(default_factory=list) - - """Represents the 'juju statuses' of the application/unit being tested.""" + """Contents of a charm's stored state.""" # the current statuses. Will be cast to _EntitiyStatus in __post_init__ app_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + """Status of the application.""" unit_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + """Status of the unit.""" workload_version: str = "" + """Workload version.""" def __post_init__(self): for name in ["app_status", "unit_status"]: @@ -897,6 +970,8 @@ class Event(_DCBase): args: Tuple[Any] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) + # if this is a storage event, the storage it refers to + storage: Optional["Storage"] = None # if this is a relation event, the relation it refers to relation: Optional["AnyRelation"] = None # and the name of the remote unit this relation event is about diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 536fdab2..65ff83d2 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -1,6 +1,7 @@ import importlib import sys import tempfile +from contextlib import contextmanager from pathlib import Path from typing import Type @@ -18,20 +19,26 @@ class MyCharm(CharmBase): pass """ +@contextmanager def import_name(name: str, source: Path) -> Type[CharmType]: pkg_path = str(source.parent) sys.path.append(pkg_path) charm = importlib.import_module("charm") obj = getattr(charm, name) sys.path.remove(pkg_path) - return obj + yield obj + del sys.modules["charm"] +@contextmanager def create_tempcharm( - charm: str = CHARM, meta=None, actions=None, config=None, name: str = "MyCharm" + root: Path, + charm: str = CHARM, + meta=None, + actions=None, + config=None, + name: str = "MyCharm", ): - root = Path(tempfile.TemporaryDirectory().name) - src = root / "src" src.mkdir(parents=True) charmpy = src / "charm.py" @@ -46,35 +53,40 @@ def create_tempcharm( if config is not None: (root / "config.yaml").write_text(yaml.safe_dump(config)) - return import_name(name, charmpy) + with import_name(name, charmpy) as charm: + yield charm def test_meta_autoload(tmp_path): - charm = create_tempcharm(meta={"name": "foo"}) - ctx = Context(charm) - ctx.run("start", State()) + with create_tempcharm(tmp_path, meta={"name": "foo"}) as charm: + ctx = Context(charm) + ctx.run("start", State()) def test_no_meta_raises(tmp_path): - charm = create_tempcharm() - with pytest.raises(ContextSetupError): - Context(charm) + with create_tempcharm( + tmp_path, + ) as charm: + # metadata not found: + with pytest.raises(ContextSetupError): + Context(charm) def test_relations_ok(tmp_path): - charm = create_tempcharm( - meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}} - ) - # this would fail if there were no 'cuddles' relation defined in meta - Context(charm).run("start", State(relations=[Relation("cuddles")])) + with create_tempcharm( + tmp_path, meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}} + ) as charm: + # this would fail if there were no 'cuddles' relation defined in meta + Context(charm).run("start", State(relations=[Relation("cuddles")])) def test_config_defaults(tmp_path): - charm = create_tempcharm( + with create_tempcharm( + tmp_path, meta={"name": "josh"}, config={"options": {"foo": {"type": "bool", "default": True}}}, - ) - # this would fail if there were no 'cuddles' relation defined in meta - with Context(charm).manager("start", State()) as mgr: - mgr.run() - assert mgr.charm.config["foo"] is True + ) as charm: + # this would fail if there were no 'cuddles' relation defined in meta + with Context(charm).manager("start", State()) as mgr: + mgr.run() + assert mgr.charm.config["foo"] is True diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index e26fb3ad..062f32d4 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -12,6 +12,7 @@ Relation, Secret, State, + Storage, SubordinateRelation, _CharmSpec, ) @@ -342,7 +343,7 @@ def test_relation_without_endpoint(): relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] ), Event("start"), - _CharmSpec(MyCharm, meta={}), + _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( @@ -357,3 +358,58 @@ def test_relation_without_endpoint(): }, ), ) + + +def test_storage_event(): + storage = Storage("foo") + assert_inconsistent( + State(storage=[storage]), + Event("foo-storage-attached"), + _CharmSpec(MyCharm, meta={"name": "rupert"}), + ) + assert_inconsistent( + State(storage=[storage]), + Event("foo-storage-attached"), + _CharmSpec( + MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} + ), + ) + assert_consistent( + State(storage=[storage]), + storage.attached_event, + _CharmSpec( + MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} + ), + ) + + +def test_storage_states(): + storage1 = Storage("foo", index=1) + storage2 = Storage("foo", index=1) + + assert_inconsistent( + State(storage=[storage1, storage2]), + Event("start"), + _CharmSpec(MyCharm, meta={"name": "everett"}), + ) + assert_consistent( + State(storage=[storage1, storage2.replace(index=2)]), + Event("start"), + _CharmSpec( + MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} + ), + ) + assert_consistent( + State(storage=[storage1, storage2.replace(name="marx")]), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "engels", + "storage": { + "foo": {"type": "filesystem"}, + "marx": {"type": "filesystem"}, + }, + }, + ), + ) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index f06f7007..a0243973 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -183,6 +183,7 @@ def event_handler(charm: CharmBase, _): def pre_event(charm: CharmBase): assert charm.model.get_relation("foo") + assert charm.model.app.planned_units() == 4 # this would NOT raise an exception because we're not in an event context! # we're right before the event context is entered in fact. @@ -201,6 +202,7 @@ def pre_event(charm: CharmBase): ) state = State( leader=True, + planned_units=4, relations=[relation], ) diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index e5419294..0c28f7e6 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -1,10 +1,17 @@ import pytest from ops.charm import CharmBase from ops.framework import Framework -from ops.model import ActiveStatus, BlockedStatus, UnknownStatus, WaitingStatus +from ops.model import ( + ActiveStatus, + BlockedStatus, + ErrorStatus, + MaintenanceStatus, + UnknownStatus, + WaitingStatus, +) from scenario import Context -from scenario.state import State +from scenario.state import State, _status_to_entitystatus from tests.helpers import trigger @@ -118,3 +125,21 @@ def post_event(charm: CharmBase): assert ctx.workload_version_history == ["1", "1.1"] assert out.workload_version == "1.2" + + +@pytest.mark.parametrize( + "status", + ( + ActiveStatus("foo"), + WaitingStatus("bar"), + BlockedStatus("baz"), + MaintenanceStatus("qux"), + ErrorStatus("fiz"), + UnknownStatus(), + ), +) +def test_status_comparison(status): + entitystatus = _status_to_entitystatus(status) + assert entitystatus == entitystatus == status + assert isinstance(entitystatus, type(status)) + assert repr(entitystatus) == repr(status) diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py new file mode 100644 index 00000000..a33893f7 --- /dev/null +++ b/tests/test_e2e/test_storage.py @@ -0,0 +1,81 @@ +import pytest +from ops import CharmBase, ModelError + +from scenario import Context, State, Storage + + +class MyCharmWithStorage(CharmBase): + META = {"name": "charlene", "storage": {"foo": {"type": "filesystem"}}} + + +class MyCharmWithoutStorage(CharmBase): + META = {"name": "patrick"} + + +@pytest.fixture +def storage_ctx(): + return Context(MyCharmWithStorage, meta=MyCharmWithStorage.META) + + +@pytest.fixture +def no_storage_ctx(): + return Context(MyCharmWithoutStorage, meta=MyCharmWithoutStorage.META) + + +def test_storage_get_null(no_storage_ctx): + with no_storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + assert not len(storages) + + +def test_storage_get_unknown_name(storage_ctx): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + # not in metadata + with pytest.raises(KeyError): + storages["bar"] + + +def test_storage_request_unknown_name(storage_ctx): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + # not in metadata + with pytest.raises(ModelError): + storages.request("bar") + + +def test_storage_get_some(storage_ctx): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + # known but none attached + assert storages["foo"] == [] + + +@pytest.mark.parametrize("n", (1, 3, 5)) +def test_storage_add(storage_ctx, n): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + storages.request("foo", n) + + assert storage_ctx.requested_storages["foo"] == n + + +def test_storage_usage(storage_ctx): + storage = Storage("foo") + # setup storage with some content + (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") + + with storage_ctx.manager("update-status", State(storage=[storage])) as mgr: + foo = mgr.charm.model.storages["foo"][0] + loc = foo.location + path = loc / "myfile.txt" + assert path.exists() + assert path.read_text() == "helloworld" + + myfile = loc / "path.py" + myfile.write_text("helloworlds") + + # post-mortem: inspect fs contents. + assert ( + storage.get_filesystem(storage_ctx) / "path.py" + ).read_text() == "helloworlds"