Repository: canonical/operator
Pull request #54 (2019-11-25 16:04:24)

Add resources to the model
Add a mapping of resource names to the fetched path of the resource. The resources are lazily fetched on access.


Patch:
diff --git a/op/main.py b/op/main.py
index f425ba0..14e2971 100644
--- a/op/main.py
+++ b/op/main.py
@@ -140,7 +140,7 @@ def main(charm_class):
 
     meta = op.charm.CharmMeta(_load_metadata(charm_dir))
     unit_name = os.environ['JUJU_UNIT_NAME']
-    model = op.model.Model(unit_name, list(meta.relations), op.model.ModelBackend())
+    model = op.model.Model(unit_name, meta, op.model.ModelBackend())
 
     # TODO: If Juju unit agent crashes after exit(0) from the charm code
     # the framework will commit the snapshot but Juju will not commit its
diff --git a/op/model.py b/op/model.py
index 2284adb..0291fda 100644
--- a/op/model.py
+++ b/op/model.py
@@ -10,13 +10,14 @@ from subprocess import run, PIPE, CalledProcessError
 
 
 class Model:
-    def __init__(self, unit_name, relation_names, backend):
+    def __init__(self, unit_name, meta, backend):
         self._cache = ModelCache(backend)
         self._backend = backend
         self.unit = self.get_unit(unit_name)
         self.app = self.unit.app
-        self.relations = RelationMapping(relation_names, self.unit, self._backend, self._cache)
+        self.relations = RelationMapping(list(meta.relations), self.unit, self._backend, self._cache)
         self.config = ConfigData(self._backend)
+        self.resources = Resources(list(meta.resources), self._backend)
         self.pod = Pod(self._backend)
 
     def get_relation(self, relation_name, relation_id=None):
@@ -242,6 +243,27 @@ class ConfigData(LazyMapping):
         return self._backend.config_get()
 
 
+class Resources:
+    """Object representing resources for the charm.
+    """
+
+    def __init__(self, names, backend):
+        self._backend = backend
+        self._paths = {name: None for name in names}
+
+    def fetch(self, name):
+        """Fetch the resource from the controller or store.
+
+        If successfully fetched, this returns a Path object to where the resource is stored
+        on disk, otherwise it raises a CalledProcessError.
+        """
+        if name not in self._paths:
+            raise RuntimeError(f'invalid resource name: {name}')
+        if self._paths[name] is None:
+            self._paths[name] = Path(self._backend.resource_get(name))
+        return self._paths[name]
+
+
 class Pod:
     def __init__(self, backend):
         self._backend = backend
@@ -275,14 +297,20 @@ class ModelBackend:
         self.unit_name = os.environ['JUJU_UNIT_NAME']
         self.app_name = self.unit_name.split('/')[0]
 
-    def _run(self, *args):
-        result = run(args + ('--format=json',), stdout=PIPE, stderr=PIPE, check=True)
-        text = result.stdout.decode('utf8')
-        data = json.loads(text)
-        return data
-
-    def _run_no_output(self, *args):
-        run(args, check=True)
+    def _run(self, *args, capture_output=True, use_json=True):
+        if capture_output:
+            kwargs = dict(stdout=PIPE, stderr=PIPE)
+            if use_json:
+                args += ('--format=json',)
+        else:
+            kwargs = dict()
+        result = run(args, check=True, **kwargs)
+        if capture_output:
+            text = result.stdout.decode('utf8')
+            if use_json:
+                return json.loads(text)
+            else:
+                return text
 
     def relation_ids(self, relation_name):
         relation_ids = self._run('relation-ids', relation_name)
@@ -314,7 +342,7 @@ class ModelBackend:
 
     def relation_set(self, relation_id, key, value):
         try:
-            return self._run_no_output('relation-set', '-r', str(relation_id), f'{key}={value}')
+            return self._run('relation-set', '-r', str(relation_id), f'{key}={value}', capture_output=False)
         except CalledProcessError as e:
             if b'relation not found' in e.stderr:
                 raise RelationNotFound() from e
@@ -327,6 +355,9 @@ class ModelBackend:
     def is_leader(self):
         return self._run('is-leader')
 
+    def resource_get(self, resource_name):
+        return self._run('resource-get', resource_name, use_json=False).strip()
+
     def pod_spec_set(self, spec, k8s_resources):
         tmpdir = Path(tempfile.mkdtemp('-pod-spec-set'))
         try:
@@ -337,6 +368,6 @@ class ModelBackend:
                 k8s_res_path = tmpdir / 'k8s-resources.json'
                 k8s_res_path.write_text(json.dumps(k8s_resources))
                 args.extend(['--k8s-resources', str(k8s_res_path)])
-            self._run_no_output('pod-spec-set', *args)
+            self._run('pod-spec-set', *args, capture_output=False)
         finally:
             shutil.rmtree(tmpdir)


Test patch:
diff --git a/test/test_charm.py b/test/test_charm.py
index cb9dfe3..ee671cf 100755
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -42,7 +42,7 @@ class TestCharm(unittest.TestCase):
         self.addCleanup(cleanup)
 
     def create_framework(self):
-        model = Model('local/0', list(self.meta.relations), ModelBackend())
+        model = Model('local/0', self.meta, ModelBackend())
         framework = Framework(self.tmpdir / "framework.data", self.tmpdir, self.meta, model)
         self.addCleanup(framework.close)
         return framework
diff --git a/test/test_model.py b/test/test_model.py
index 50dc150..7681864 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -8,6 +8,7 @@ import shutil
 import unittest
 
 import op.model
+import op.charm
 
 
 # TODO: We need some manner of test to validate the actual ModelBackend implementation, round-tripped
@@ -73,7 +74,9 @@ class TestModel(unittest.TestCase):
 
     def setUp(self):
         self.backend = FakeModelBackend()
-        self.model = op.model.Model('myapp/0', ['db0', 'db1', 'db2'], self.backend)
+        meta = op.charm.CharmMeta()
+        meta.relations = {'db0': None, 'db1': None, 'db2': None}
+        self.model = op.model.Model('myapp/0', meta, self.backend)
 
         os.environ['JUJU_UNIT_NAME'] = 'myapp/0'
         self.addCleanup(os.environ.pop, 'JUJU_UNIT_NAME')
@@ -176,7 +179,9 @@ class TestModel(unittest.TestCase):
 
     def test_is_leader(self):
         self.backend = op.model.ModelBackend()
-        self.model = op.model.Model('myapp/0', ['db0', 'db1', 'db2'], self.backend)
+        meta = op.charm.CharmMeta()
+        meta.relations = {'db0': None, 'db1': None, 'db2': None}
+        self.model = op.model.Model('myapp/0', meta, self.backend)
 
         def check_remote_units():
             fake_script(self, 'relation-ids',
@@ -196,7 +201,7 @@ class TestModel(unittest.TestCase):
         check_remote_units()
 
         self.backend = op.model.ModelBackend()
-        self.model = op.model.Model('myapp/0', ['db0', 'db1', 'db2'], self.backend)
+        self.model = op.model.Model('myapp/0', meta, self.backend)
 
         fake_script(self, 'is-leader', 'echo false')
         self.assertFalse(self.model.unit.is_leader())
@@ -212,8 +217,27 @@ class TestModel(unittest.TestCase):
             ['relation-list', '-r', '4', '--format=json'],
         ])
 
+    def test_resources(self):
+        backend = op.model.ModelBackend()
+        meta = op.charm.CharmMeta()
+        meta.resources = {'foo': None, 'bar': None}
+        model = op.model.Model('myapp/0', meta, backend)
+
+        with self.assertRaises(RuntimeError):
+            model.resources.fetch('qux')
+
+        fake_script(self, 'resource-get', 'exit 1')
+        with self.assertRaises(subprocess.CalledProcessError):
+            model.resources.fetch('foo')
+
+        fake_script(self, 'resource-get', 'echo /var/lib/juju/agents/unit-test-0/resources/$1/$1.tgz')
+        self.assertEqual(model.resources.fetch('foo').name, 'foo.tgz')
+        self.assertEqual(model.resources.fetch('bar').name, 'bar.tgz')
+
     def test_pod_spec(self):
-        model = op.model.Model('myapp/0', ['db0', 'db1', 'db2'], op.model.ModelBackend())
+        meta = op.charm.CharmMeta()
+        meta.relations = {'db0': None, 'db1': None, 'db2': None}
+        model = op.model.Model('myapp/0', meta, op.model.ModelBackend())
 
         fake_script(self, 'pod-spec-set', """
                     cat $2 > $(dirname $0)/spec.json