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
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.
@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
import theblues.charmstore
import theblues.errors
+from . import tag
from .client import client
from .client import watcher
from .client import connection
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:
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(
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):
"""
# 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,
def user(username):
return _prefix('user-', username)
+
+
+def application(app_name):
+ return _prefix('application-', app_name)
--- /dev/null
+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"]
--- /dev/null
+name: charm
+series: ["xenial"]
+summary: "test"
+description: "test"
+maintainers: ["test"]
import asyncio
from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
import pytest
from .. import base
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'