Repository: canonical/operator (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

Pull request description:
Add storagemappings to ContainerMeta as ContainerStorageMeta
Add a `.mounts` properties to `ContainerMeta`, so charm authors can check:

`self.containers[name].mounts[key].location`

If there are multiple mount points for the same storage, this will raise a `RuntimeError` with a message indicating that the `.locations` property should be used instead, such as:

`self.containers[name].mounts[key].locations`

Closes #579 

Patch:
diff --git a/ops/charm.py b/ops/charm.py
index 85c4a4d..11ed17f 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 78dd4aa..556c606 100755
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -24,6 +24,7 @@ from ops.charm import (
     CharmMeta,
     CharmEvents,
     ContainerMeta,
+    ContainerStorageMeta,
 )
 from ops.framework import Framework, EventSource, EventBase
 from ops.model import Model, _ModelBackend
@@ -430,3 +431,51 @@ containers:
         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