From 6637bf37c99d012ccd51823501dd7325ba3d6840 Mon Sep 17 00:00:00 2001 From: Cory Johns Date: Mon, 3 Apr 2017 15:05:04 -0400 Subject: [PATCH] Include resources from store when deploying (#102) --- juju/client/client.py | 7 ++++ juju/client/overrides.py | 39 ++++++++++++++++++++++- juju/model.py | 46 +++++++++++++++++++++++++-- juju/tag.py | 4 +++ tests/integration/bundle/bundle.yaml | 12 +++++++ tests/integration/charm/metadata.yaml | 5 +++ tests/integration/test_model.py | 37 +++++++++++++++++++++ 7 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/integration/bundle/bundle.yaml create mode 100644 tests/integration/charm/metadata.yaml diff --git a/juju/client/client.py b/juju/client/client.py index 2fc8847..f4eef0e 100644 --- a/juju/client/client.py +++ b/juju/client/client.py @@ -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 diff --git a/juju/client/overrides.py b/juju/client/overrides.py index 3f3caa4..2a6923b 100644 --- a/juju/client/overrides.py +++ b/juju/client/overrides.py @@ -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 diff --git a/juju/model.py b/juju/model.py index 0278ae6..c76ce88 100644 --- a/juju/model.py +++ b/juju/model.py @@ -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, diff --git a/juju/tag.py b/juju/tag.py index 92c54c1..2514229 100644 --- a/juju/tag.py +++ b/juju/tag.py @@ -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 index 0000000..82a963c --- /dev/null +++ b/tests/integration/bundle/bundle.yaml @@ -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 index 0000000..74eab3d --- /dev/null +++ b/tests/integration/charm/metadata.yaml @@ -0,0 +1,5 @@ +name: charm +series: ["xenial"] +summary: "test" +description: "test" +maintainers: ["test"] diff --git a/tests/integration/test_model.py b/tests/integration/test_model.py index 4d45a1c..96c786a 100644 --- a/tests/integration/test_model.py +++ b/tests/integration/test_model.py @@ -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' -- 2.25.1