Repository: canonical/operator (language: python)

Issue:
ingest mounted location into CharmMeta
Obtaining storage location via the storages section,
```python
self.meta.storages["data"].location
```
is not very useful for k8s charms because for k8s charms that sepcifies mount point in the charm container, not the workload.
For the workload, would need something like:
```python
self.meta.containers[self._container_name].mounts[0].location
```
or
```python
self.meta.containers[self._container_name].mounts.data.location
```

However, mounted location is not ingested by CharmMeta
https://github.com/canonical/operator/blob/b1a8fffc053fbbdaf43b826e78c220bfdae4c8fe/ops/charm.py#L891-L892


Discussion / hints:
Can you give an example of the charm metadata YAML? Charm Metadata V2 (not yet fully supported by OF) allows [specifying mounts under containers](https://discourse.charmhub.io/t/charm-metadata-v2/3674/15?u=rbarry). This is likely to be the only way in which this is supported by OF, instead of trying to iterate through the running containers to create this "in reverse".

Does that work?
The relevant section of metadata.yaml:
```yaml
        name: DummyConsumerCharm
        storage:
          data:
            type: filesystem
            location: /this/is/charm
        containers:
          consumer-charm:
            resource: consumer-charm-image
            mounts:
              - storage: data
                location: /this/is/workload
```
I would like to be able to
```python
# works
"/this/is/charm" == self.harness.charm.meta.storages["data"].location

# not yet implemented
"/this/is/workload" == self.harness.charm.meta.containers["alertmanager"].mounts["data"].location
```
But can't because:
https://github.com/canonical/operator/blob/3d0c36479109214dbe737f4ce8388e870a09e34b/ops/charm.py#L734-L738

which is exactly what this issue is about :)
Thanks. That layout (containers -> charm -> mounts) is what I was hoping for, and a reasonable implementation rather than a doubly-linked list cross-referencing separate container and storage nodes.

Fix patch:
diff --git a/ops/charm.py b/ops/charm.py
index 85c4a4dbd..11ed17fe1 100755
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -893,7 +893,92 @@ class ContainerMeta:
 
     Attributes:
         name: Name of container (key in the YAML)
+        mounts: :class:`ContainerStorageMeta` mounts available to the container
     """
-
     def __init__(self, name, raw):
         self.name = name
+        self._mounts = {}
+
+        # This is not guaranteed to be populated/is not enforced yet
+        if raw:
+            self._populate_mounts(raw.get('mounts', []))
+
+    @property
+    def mounts(self) -> typing.Dict:
+        """An accessor for the mounts in a container.
+
+        Dict keys match key name in :class:`StorageMeta`
+
+        Example::
+
+            storage:
+              foo:
+                type: filesystem
+                location: /test
+            containers:
+              bar:
+                mounts:
+                  - storage: foo
+                  - location: /test/mount
+        """
+        return self._mounts
+
+    def _populate_mounts(self, mounts: typing.List):
+        """Populate a list of container mountpoints.
+
+        Since Charm Metadata v2 specifies the mounts as a List, do a little data manipulation
+        to convert the values to "friendly" names which contain a list of mountpoints
+        under each key.
+        """
+        for mount in mounts:
+            storage = mount.get("storage", "")
+            mount = mount.get("location", "")
+
+            if not mount:
+                continue
+
+            if storage in self._mounts:
+                self._mounts[storage].add_location(mount)
+            else:
+                self._mounts[storage] = ContainerStorageMeta(storage, mount)
+
+
+class ContainerStorageMeta:
+    """Metadata about storage for an individual container.
+
+    Attributes:
+        storage: a name for the mountpoint, which should exist the keys for :class:`StorageMeta`
+                 for the charm
+        location: the location `storage` is mounted at
+        locations: a list of mountpoints for the key
+
+    If multiple locations are specified for the same storage, such as Kubernetes subPath mounts,
+    `location` will not be an accessible attribute, as it would not be possible to determine
+    which mount point was desired, and `locations` should be iterated over.
+    """
+    def __init__(self, storage, location):
+        self.storage = storage
+        self._locations = [location]
+
+    def add_location(self, location):
+        """Add an additional mountpoint to a known storage."""
+        self._locations.append(location)
+
+    @property
+    def locations(self) -> typing.List:
+        """An accessor for the list of locations for a mount."""
+        return self._locations
+
+    def __getattr__(self, name):
+        if name == "location":
+            if len(self._locations) == 1:
+                return self._locations[0]
+            else:
+                raise RuntimeError(
+                    "container has more than one mountpoint with the same backing storage. "
+                    "Request .locations to see a list"
+                )
+        else:
+            raise AttributeError(
+                "{.__class__.__name__} has no such attribute: {}!".format(self, name)
+            )


Test patch:
diff --git a/test/test_charm.py b/test/test_charm.py
index 78dd4aa66..556c606d8 100755
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -24,6 +24,7 @@
     CharmMeta,
     CharmEvents,
     ContainerMeta,
+    ContainerStorageMeta,
 )
 from ops.framework import Framework, EventSource, EventBase
 from ops.model import Model, _ModelBackend
@@ -430,3 +431,51 @@ def test_containers(self):
         self.assertIsInstance(meta.containers['test2'], ContainerMeta)
         self.assertEqual(meta.containers['test1'].name, 'test1')
         self.assertEqual(meta.containers['test2'].name, 'test2')
+
+    def test_containers_storage(self):
+        meta = CharmMeta.from_yaml("""
+name: k8s-charm
+storage:
+  data:
+    type: filesystem
+    location: /test/storage
+  other:
+    type: filesystem
+    location: /test/other
+containers:
+  test1:
+    mounts:
+      - storage: data
+        location: /test/storagemount
+      - storage: other
+        location: /test/otherdata
+""")
+        self.assertIsInstance(meta.containers['test1'], ContainerMeta)
+        self.assertIsInstance(meta.containers['test1'].mounts["data"], ContainerStorageMeta)
+        self.assertEqual(meta.containers['test1'].mounts["data"].location, '/test/storagemount')
+        self.assertEqual(meta.containers['test1'].mounts["other"].location, '/test/otherdata')
+
+    def test_containers_storage_multiple_mounts(self):
+        meta = CharmMeta.from_yaml("""
+name: k8s-charm
+storage:
+  data:
+    type: filesystem
+    location: /test/storage
+containers:
+  test1:
+    mounts:
+      - storage: data
+        location: /test/storagemount
+      - storage: data
+        location: /test/otherdata
+""")
+        self.assertIsInstance(meta.containers['test1'], ContainerMeta)
+        self.assertIsInstance(meta.containers['test1'].mounts["data"], ContainerStorageMeta)
+        self.assertEqual(
+                meta.containers['test1'].mounts["data"].locations[0],
+                '/test/storagemount')
+        self.assertEqual(meta.containers['test1'].mounts["data"].locations[1], '/test/otherdata')
+
+        with self.assertRaises(RuntimeError):
+            meta.containers["test1"].mounts["data"].location