Include resources from store when deploying (#102)
authorCory Johns <johnsca@gmail.com>
Mon, 3 Apr 2017 19:05:04 +0000 (15:05 -0400)
committerPete Vander Giessen <petevg@gmail.com>
Mon, 3 Apr 2017 19:05:04 +0000 (15:05 -0400)
juju/client/client.py
juju/client/overrides.py
juju/model.py
juju/tag.py
tests/integration/bundle/bundle.yaml [new file with mode: 0644]
tests/integration/charm/metadata.yaml [new file with mode: 0644]
tests/integration/test_model.py

index 2fc8847..f4eef0e 100644 (file)
@@ -9,4 +9,11 @@ from . import overrides
 for o in overrides.__all__:
     setattr(_client, o, getattr(overrides, o))
 
+for o in overrides.__patches__:
+    c_type = getattr(_client, o)
+    o_type = getattr(overrides, o)
+    for a in dir(o_type):
+        if not a.startswith('_'):
+            setattr(c_type, a, getattr(o_type, a))
+
 from ._client import *  # noqa
index 3f3caa4..2a6923b 100644 (file)
@@ -1,11 +1,16 @@
 from collections import namedtuple
 
-from .facade import Type
+from .facade import ReturnMapping, Type
+from .import _client
 
 __all__ = [
     'Delta',
 ]
 
+__patches__ = [
+    'ResourcesFacade',
+]
+
 
 class Delta(Type):
     """A single websocket delta.
@@ -43,3 +48,35 @@ class Delta(Type):
     @classmethod
     def from_json(cls, data):
         return cls(deltas=data)
+
+
+class ResourcesFacade(Type):
+    """Patch parts of ResourcesFacade to make it work.
+    """
+
+    @ReturnMapping(_client.AddPendingResourcesResult)
+    async def AddPendingResources(self, application_tag, charm_url, resources):
+        """Fix the calling signature of AddPendingResources.
+
+        The ResourcesFacade doesn't conform to the standard facade pattern in
+        the Juju source, which leads to the schemagened code not matching up
+        properly with the actual calling convention in the API.  There is work
+        planned to fix this in Juju, but we have to work around it for now.
+
+        application_tag : str
+        charm_url : str
+        resources : typing.Sequence<+T_co>[~CharmResource]<~CharmResource>
+        Returns -> typing.Union[_ForwardRef('ErrorResult'),
+                                typing.Sequence<+T_co>[str]]
+        """
+        # map input types to rpc msg
+        _params = dict()
+        msg = dict(type='Resources',
+                   request='AddPendingResources',
+                   version=1,
+                   params=_params)
+        _params['tag'] = application_tag
+        _params['url'] = charm_url
+        _params['resources'] = resources
+        reply = await self.rpc(msg)
+        return reply
index 0278ae6..c76ce88 100644 (file)
@@ -16,6 +16,7 @@ import yaml
 import theblues.charmstore
 import theblues.errors
 
+from . import tag
 from .client import client
 from .client import watcher
 from .client import connection
@@ -992,9 +993,7 @@ class Model(object):
 
         TODO::
 
-            - application_name is required; fill this in automatically if not
-              provided by caller
-            - series is required; how do we pick a default?
+            - support local resources
 
         """
         if storage:
@@ -1046,6 +1045,11 @@ class Model(object):
                 if not channel:
                     channel = 'stable'
                 await client_facade.AddCharm(channel, entity_id)
+                # XXX: we're dropping local resources here, but we don't
+                # actually support them yet anyway
+                resources = await self._add_store_resources(application_name,
+                                                            entity_id,
+                                                            entity)
             else:
                 # We have a local charm dir that needs to be uploaded
                 charm_dir = os.path.abspath(
@@ -1071,6 +1075,37 @@ class Model(object):
                 placement=parse_placement(to),
             )
 
+    async def _add_store_resources(self, application, entity_url, entity=None):
+        if not entity:
+            # avoid extra charm store call if one was already made
+            entity = await self.charmstore.entity(entity_url)
+        resources = [
+            {
+                'description': resource['Description'],
+                'fingerprint': resource['Fingerprint'],
+                'name': resource['Name'],
+                'path': resource['Path'],
+                'revision': resource['Revision'],
+                'size': resource['Size'],
+                'type_': resource['Type'],
+                'origin': 'store',
+            } for resource in entity['Meta']['resources']
+        ]
+
+        if not resources:
+            return None
+
+        resources_facade = client.ResourcesFacade()
+        resources_facade.connect(self.connection)
+        response = await resources_facade.AddPendingResources(
+            tag.application(application),
+            entity_url,
+            [client.CharmResource(**resource) for resource in resources])
+        resource_map = {resource['name']: pid
+                        for resource, pid
+                        in zip(resources, response.pending_ids)}
+        return resource_map
+
     async def _deploy(self, charm_url, application, series, config,
                       constraints, endpoint_bindings, resources, storage,
                       channel=None, num_units=None, placement=None):
@@ -1707,6 +1742,11 @@ class BundleHandler(object):
         """
         # resolve indirect references
         charm = self.resolve(charm)
+        # the bundle plan doesn't actually do anything with resources, even
+        # though it ostensibly gives us something (None) for that param
+        if not charm.startswith('local:'):
+            resources = await self.model._add_store_resources(application,
+                                                              charm)
         await self.model._deploy(
             charm_url=charm,
             application=application,
index 92c54c1..2514229 100644 (file)
@@ -25,3 +25,7 @@ def model(cloud_name):
 
 def user(username):
     return _prefix('user-', username)
+
+
+def application(app_name):
+    return _prefix('application-', app_name)
diff --git a/tests/integration/bundle/bundle.yaml b/tests/integration/bundle/bundle.yaml
new file mode 100644 (file)
index 0000000..82a963c
--- /dev/null
@@ -0,0 +1,12 @@
+series: xenial
+services:
+  ghost:
+    charm: "cs:ghost-18"
+    num_units: 1
+  mysql:
+    charm: "cs:trusty/mysql-57"
+    num_units: 1
+  test:
+    charm: "./tests/integration/charm"
+relations:
+  - ["ghost", "mysql"]
diff --git a/tests/integration/charm/metadata.yaml b/tests/integration/charm/metadata.yaml
new file mode 100644 (file)
index 0000000..74eab3d
--- /dev/null
@@ -0,0 +1,5 @@
+name: charm
+series: ["xenial"]
+summary: "test"
+description: "test"
+maintainers: ["test"]
index 4d45a1c..96c786a 100644 (file)
@@ -1,5 +1,6 @@
 import asyncio
 from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
 import pytest
 
 from .. import base
@@ -135,3 +136,39 @@ async def test_explicit_loop_threaded(event_loop):
             f.result()
         await model._wait_for_new('application', 'ubuntu')
         assert 'ubuntu' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_charm(event_loop):
+    async with base.CleanModel() as model:
+        ghost = await model.deploy('cs:ghost-18')
+        assert 'ghost' in model.applications
+        terminal_statuses = ('active', 'error', 'blocked')
+        await model.block_until(
+            lambda: (
+                len(ghost.units) > 0 and
+                ghost.units[0].workload_status in terminal_statuses)
+            )
+        # ghost will go in to blocked (or error, for older
+        # charm revs) if the resource is missing
+        assert ghost.units[0].workload_status == 'active'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_bundle(event_loop):
+    async with base.CleanModel() as model:
+        bundle = str(Path(__file__).parent / 'bundle')
+        await model.deploy(bundle)
+        assert 'ghost' in model.applications
+        ghost = model.applications['ghost']
+        terminal_statuses = ('active', 'error', 'blocked')
+        await model.block_until(
+            lambda: (
+                len(ghost.units) > 0 and
+                ghost.units[0].workload_status in terminal_statuses)
+            )
+        # ghost will go in to blocked (or error, for older
+        # charm revs) if the resource is missing
+        assert ghost.units[0].workload_status == 'active'