diff --git a/examples/local_refresh.py b/examples/local_refresh.py index b2fe26b5..6b7d5ae6 100644 --- a/examples/local_refresh.py +++ b/examples/local_refresh.py @@ -1,13 +1,3 @@ -# Copyright 2023 Canonical Ltd. -# Licensed under the Apache V2, see LICENCE file for details. - -""" -This example: - -1. Connects to the current model -2. Upgrades previously deployed ubuntu charm - -""" from juju import jasyncio from juju.model import Model @@ -15,19 +5,26 @@ async def main(): model = Model() print('Connecting to model') - # connect to current model with current user, per Juju CLI await model.connect() - try: - print('Get deployed application') - app = model.appplications["ubuntu"] - - print('Refresh/Upgrade Ubuntu charm with local charm') - await app.refresh(path="path/to/local/ubuntu.charm") + print('path="/home/jack/charms/ubuntu"') + await depl(model, path="/home/jack/charms/ubuntu") + print('switch="/home/jack/charms/ubuntu"') + await depl(model, switch="/home/jack/charms/ubuntu") + print('switch="local:/home/jack/charms/ubuntu"') + await depl(model, switch="local:/home/jack/charms/ubuntu") finally: print('Disconnecting from model') await model.disconnect() +async def depl(model, **kwargs): + try: + app = await model.deploy("ubuntu") + await model.block_until(lambda: app.units[0].workload_status == 'active') + await app.refresh(**kwargs) + finally: + await app.remove() + await model.block_until(lambda: not len(model.applications)) if __name__ == '__main__': jasyncio.run(main()) diff --git a/juju/bundle.py b/juju/bundle.py index 6637af7c..796f944c 100644 --- a/juju/bundle.py +++ b/juju/bundle.py @@ -223,24 +223,28 @@ def _resolve_include_file_config(self, bundle_dir): return self.bundle, self.overlays - async def fetch_plan(self, charm_url, origin, overlays=[]): - entity_id = charm_url.path() - is_local = Schema.LOCAL.matches(charm_url.schema) + async def fetch_plan(self, bundle, origin, overlays=[]): bundle_dir = None - if is_local and os.path.isfile(entity_id): - bundle_yaml = Path(entity_id).read_text() - bundle_dir = Path(entity_id).parent - elif is_local and os.path.isdir(entity_id): - bundle_yaml = (Path(entity_id) / "bundle.yaml").read_text() - bundle_dir = Path(entity_id) + if is_local_bundle(str(bundle)): + path = str(bundle) + if path.startswith("local:"): + path = path[6:] + bundle_yaml, bundle_dir = read_local_bundle(path) - if Schema.CHARM_STORE.matches(charm_url.schema): - bundle_yaml = await self.charmstore.files(entity_id, - filename='bundle.yaml', - read_file=True) - elif Schema.CHARM_HUB.matches(charm_url.schema): - bundle_yaml = await self._download_bundle(charm_url, origin) + else: + if client.CharmsFacade.best_facade_version(self.connection()) < 3: + url = URL.parse(bundle, default_store=Schema.CHARM_STORE) + else: + url = URL.parse(bundle) + path = url.path + + if Schema.CHARM_STORE.matches(url.schema): + bundle_yaml = await self.charmstore.files(bundle, + filename='bundle.yaml', + read_file=True) + elif Schema.CHARM_HUB.matches(url.schema): + bundle_yaml = await self._download_bundle(bundle, origin) if not bundle_yaml: raise JujuError('empty bundle, nothing to deploy') @@ -267,7 +271,7 @@ async def fetch_plan(self, charm_url, origin, overlays=[]): self.bundle = await self._validate_bundle(self.bundle) - if is_local: + if is_local_bundle(path): self.bundle = await self._handle_local_charms(self.bundle, bundle_dir) self.bundle, self.overlays = self._resolve_include_file_config(bundle_dir) @@ -278,7 +282,7 @@ async def fetch_plan(self, charm_url, origin, overlays=[]): yaml_data = "---\n".join(_yaml_data) self.plan = await self.bundle_facade.GetChanges( - bundleurl=entity_id, + bundleurl=path, yaml=yaml_data) if self.plan.errors: @@ -416,6 +420,21 @@ def is_local_charm(charm_url): return charm_url.startswith('.') or charm_url.startswith('local:') or os.path.isabs(charm_url) +is_local_bundle = is_local_charm + + +def read_local_bundle(path): + path = Path(path) + if os.path.isfile(path): + bundle_yaml = path.read_text() + bundle_dir = path.parent + elif os.path.isdir(path): + bundle_yaml = (path / "bundle.yaml").read_text() + bundle_dir = path + + return (bundle_yaml, bundle_dir) + + async def get_charm_series(metadata, model): """Inspects the given metadata and returns a default series from its metadata.yaml (the first item in the 'series' list). @@ -709,14 +728,14 @@ async def run(self, context): # We don't add local charms because they've already been added # by self._handle_local_charms - url = URL.parse(str(self.charm)) - ch = None - identifier = None - if Schema.LOCAL.matches(url.schema): + if is_local_charm(str(self.charm)): origin = client.CharmOrigin(source="local", risk="stable") context.origins[self.charm] = {str(None): origin} return self.charm + url = URL.parse(str(self.charm)) + ch = None + identifier = None if Schema.CHARM_STORE.matches(url.schema): entity_id = await context.charmstore.entityId(self.charm, channel=self.channel) log.debug('Adding %s', entity_id) diff --git a/juju/model.py b/juju/model.py index 145827f7..3b10cc02 100644 --- a/juju/model.py +++ b/juju/model.py @@ -452,18 +452,16 @@ class LocalDeployType: """LocalDeployType deals with local only deployments. """ - async def resolve(self, url, architecture, app_name=None, channel=None, series=None, revision=None, + async def resolve(self, charm_path, architecture, app_name=None, channel=None, series=None, revision=None, entity_url=None, force=False): """resolve attempts to resolve a local charm or bundle using the url and architecture. If information is missing, it will attempt to backfill that information, before sending the result back. """ - entity_url = url.path() - entity_path = Path(entity_url) + entity_path = Path(charm_path) bundle_path = entity_path / 'bundle.yaml' - identifier = entity_url origin = client.CharmOrigin(source="local", architecture=architecture) if not (entity_path.is_dir() or entity_path.is_file()): raise JujuError('{} path not found'.format(entity_url)) @@ -474,21 +472,17 @@ async def resolve(self, url, architecture, app_name=None, channel=None, series=N ) if app_name is None: - app_name = url.name - - if not is_bundle: - entity_url = url.path() - entity_path = Path(entity_url) - if entity_path.suffix == '.charm': - with zipfile.ZipFile(str(entity_path), 'r') as charm_file: - metadata = yaml.load(charm_file.read('metadata.yaml'), Loader=yaml.FullLoader) + if is_bundle: + if entity_path.suffix == '.yaml' and entity_path.exists(): + bundle = yaml.load(entity_path.read_text(), Loader=yaml.FullLoader) else: - metadata_path = entity_path / 'metadata.yaml' - metadata = yaml.load(metadata_path.read_text(), Loader=yaml.FullLoader) - app_name = metadata['name'] + bundle = yaml.load(bundle_path.read_text(), Loader=yaml.FullLoader) + app_name = bundle['name'] + else: + app_name = utils.get_local_charm_metadata(entity_path)["name"] return DeployTypeResult( - identifier=identifier, + identifier=charm_path, origin=origin, app_name=app_name, is_local=True, @@ -521,7 +515,7 @@ async def resolve(self, url, architecture, app_name=None, channel=None, series=N include_stats=False) identifier = result['Id'] - is_bundle = url.series == "bundle" or url.parse(identifier).series == "bundle" + is_bundle = URL.parse(url).series == "bundle" or URL.parse(identifier).series == "bundle" if not series: series = "bundle" if is_bundle else self.get_series(entity_url, result) @@ -578,7 +572,7 @@ async def resolve(self, url, architecture, app_name=None, channel=None, series=N charm_url = URL.parse(charm_url_str) if app_name is None: - app_name = url.name + app_name = charm_url.name return DeployTypeResult( identifier=str(charm_url), @@ -629,9 +623,9 @@ def __init__( self._charmstore = CharmStore() self.deploy_types = { - "local": LocalDeployType(), - "cs": CharmStoreDeployType(self._charmstore, self._get_series), - "ch": CharmhubDeployType(self._resolve_charm), + Schema.LOCAL: LocalDeployType(), + Schema.CHARM_STORE: CharmStoreDeployType(self._charmstore, self._get_series), + Schema.CHARM_HUB: CharmhubDeployType(self._charmhub_resolve_charm), } def is_connected(self): @@ -1603,7 +1597,7 @@ async def deploy( storage=None, to=None, devices=None, trust=False): """Deploy a new service or bundle. - :param str entity_url: Charm or bundle url + :param str entity_url: Charm or bundle to deploy. Not necessarily a url :param str application_name: Name to give the service :param dict bind: : pairs :param str channel: Charm store channel from which to retrieve @@ -1646,23 +1640,30 @@ async def deploy( raise NotImplementedError("trusted is not supported on model version {}".format(self.info.agent_version)) # Ensure what we pass in, is a string. - entity_url = str(entity_url) - if is_local_charm(entity_url) and not entity_url.startswith("local:"): - entity_url = "local:{}".format(entity_url) + entity = str(entity_url) + if is_local_charm(entity): + if entity.startswith("local:"): + entity = entity[6:] + architecture = await self._resolve_architecture() + schema = Schema.LOCAL - if client.CharmsFacade.best_facade_version(self.connection()) < 3: - url = URL.parse(str(entity_url), default_store=Schema.CHARM_STORE) else: - url = URL.parse(str(entity_url)) + if client.CharmsFacade.best_facade_version(self.connection()) < 3: + url = URL.parse(entity, default_store=Schema.CHARM_STORE) + else: + url = URL.parse(entity) + entity = str(url) - architecture = await self._resolve_architecture(url) + architecture = await self._resolve_architecture(url) + schema = url.schema + name = url.name - if str(url.schema) not in self.deploy_types: - raise JujuError("unknown deploy type {}, expected charmhub, charmstore or local".format(url.schema)) + if schema not in self.deploy_types: + raise JujuError("unknown deploy type {}, expected charmhub, charmstore or local".format(schema)) - res = await self.deploy_types[str(url.schema)].resolve(url, architecture, - application_name, channel, series, - revision, entity_url, force) + res = await self.deploy_types[schema].resolve(entity, architecture, + application_name, channel, series, + revision, entity_url, force) if res.identifier is None: raise JujuError('unknown charm or bundle {}'.format(entity_url)) @@ -1672,7 +1673,7 @@ async def deploy( charm_series = series if res.is_bundle: handler = BundleHandler(self, trusted=trust, forced=force) - await handler.fetch_plan(url, res.origin, overlays=overlays) + await handler.fetch_plan(entity, res.origin, overlays=overlays) await handler.execute_plan() extant_apps = {app for app in self.applications} pending_apps = handler.applications - extant_apps @@ -1700,11 +1701,11 @@ async def deploy( else: charm_origin = add_charm_res.charm_origin - if Schema.CHARM_HUB.matches(url.schema): + if Schema.CHARM_HUB.matches(schema): resources = await self._add_charmhub_resources(res.app_name, identifier, add_charm_res.charm_origin) - charm_info = await self.charmhub.info(url.name) + charm_info = await self.charmhub.info(name) is_subordinate = False try: is_subordinate = charm_info.result.charm.subordinate @@ -1715,7 +1716,7 @@ async def deploy( raise JujuError("cannot use num_units with subordinate application") num_units = 0 - if Schema.CHARM_STORE.matches(url.schema): + if Schema.CHARM_STORE.matches(schema): resources = await self._add_store_resources(res.app_name, identifier) else: @@ -1777,23 +1778,18 @@ async def _add_charm(self, charm_url, origin): client_facade = client.ClientFacade.from_connection(self.connection()) return await client_facade.AddCharm(channel=str(origin.risk), url=charm_url, force=False) - async def _resolve_charm(self, url, origin, force=False): + async def _charmhub_resolve_charm(self, url, origin, force=False): charms_cls = client.CharmsFacade if charms_cls.best_facade_version(self.connection()) < 3: raise JujuError("resolve charm") charms_facade = charms_cls.from_connection(self.connection()) - if Schema.CHARM_STORE.matches(url.schema): - source = "charm-store" - else: - source = "charm-hub" - resp = await charms_facade.ResolveCharms(resolve=[{ 'reference': str(url), 'charm-origin': { 'series': origin.series, - 'source': source, + 'source': "charm-hub", 'architecture': origin.architecture, 'track': origin.track, 'risk': origin.risk, @@ -1820,8 +1816,8 @@ async def _resolve_charm(self, url, origin, force=False): return result.url, result.charm_origin - async def _resolve_architecture(self, url): - if url.architecture: + async def _resolve_architecture(self, url=None): + if url is not None and url.architecture: return url.architecture constraints = await self.get_constraints() diff --git a/juju/url.py b/juju/url.py index b95b18d8..814460d5 100644 --- a/juju/url.py +++ b/juju/url.py @@ -43,13 +43,11 @@ def parse(s, default_store=Schema.CHARM_HUB): if u.query != "" or u.fragment != "" or u.username or u.password: raise JujuError("charm or bundle URL {} has unrecognized parts".format(u)) - if Schema.LOCAL.matches(u.scheme): - c = URL(Schema.LOCAL, name=u.path) - elif Schema.CHARM_STORE.matches(u.scheme) or \ + if Schema.CHARM_STORE.matches(u.scheme) or \ (u.scheme == "" and Schema.CHARM_STORE.matches(default_store)): c = parse_v1_url(Schema.CHARM_STORE, u, s) else: - c = parse_v2_url(u, s) + c = parse_v2_url(u, s, default_store) if not c or not c.schema: raise JujuError("expected schema for charm or bundle URL {}".format(u)) @@ -121,8 +119,18 @@ def parse_v1_url(schema, u, s): return c -def parse_v2_url(u, s): - c = URL(Schema.CHARM_HUB) +def parse_v2_url(u, s, default_store): + if not u.scheme: + c = URL(default_store) + elif Schema.CHARM_HUB.matches(u.scheme): + c = URL(Schema.CHARM_HUB) + elif Schema.LOCAL.matches(u.scheme): + c = URL(Schema.LOCAL) + else: + raise JujuError("invalid charm url schema {}".format(u.scheme)) + +# local:bionic/ubu-12 +# local:/path/to/cha/ubunt parts = u.path.split("/") num = len(parts) diff --git a/tests/unit/test_url.py b/tests/unit/test_url.py index 05bfebbf..68afd3dc 100644 --- a/tests/unit/test_url.py +++ b/tests/unit/test_url.py @@ -52,3 +52,7 @@ def test_parse_v2_revision(self): def test_parse_v2_large_revision(self): u = URL.parse("ch:mysql-12345") self.assertEqual(u, URL(Schema.CHARM_HUB, name="mysql", revision=12345)) + + def test_parse_v2_without_store(self): + u = URL.parse("mysql-1", default_store=Schema.CHARM_HUB) + self.assertEqual(u, URL(Schema.CHARM_HUB, name="mysql", revision=1))