Skip to content

Commit 03332e6

Browse files
authored
Merge pull request #974 from jack-w-shaw/JUJU-4779_parse_local_charms_properly-master
#974 Port forward #969 Parse charm URLs consistantly for local charms Local charm URLs are confused in pylibjuju because often a charm url-like string is passed into deploy, to explicitly specify a local charm. These 'urls' were of the form 'local:/path/to/charm' Local URLs were parsed accordingly. However, the above is in no sense a url really so should be treated as such. This also means that pylibjuju is unable to parse real local charm urls returned to the client from the server when, say, uploading a local charm. Drop support for local charms like this. Instead parse local charm urls the same way we parse charmhub charm url. This has the side-effect, however, that we can no longer treat all objects to deploy as URLs. Now, they are either URLs (ch or cs charms) or paths or paths prepended with 'local:' (local charms) As such, in deploy code and refresh code, we often need to branch on is_local_charm. This is to ensure we don't attempt to parse paths as URLs. Everywhere we URL.parse user input, we ensure the entity is not a path This resolves #961 #### QA Steps ``` tox -e unit -- tests/unit/test_url.py ``` ``` tox -e integration -- tests/integration/test_model.py::test_deploy_local_bundle_dir tox -e integration -- tests/integration/test_model.py::test_deploy_local_bundle_file tox -e integration -- tests/integration/test_model.py::test_deploy_bundle_local_charms tox -e integration -- tests/integration/test_model.py::test_deploy_local_charm ``` All CI tests need to pass. (In a python interpreter) ``` >>> from juju.url import URL >>> u = URL.parse("local:focal/ubuntu-12") >>> print(u.name) ubuntu >>> print(u.revision) 12 >>> URL.parse("local:/path/to/charm") Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/home/jack/juju/python-libjuju/juju/url.py", line 50, in parse c = parse_v2_url(u, s, default_store) File "/home/jack/juju/python-libjuju/juju/url.py", line 138, in parse_v2_url raise JujuError("charm or bundle URL {} malformed".format(s)) juju.errors.JujuError: charm or bundle URL local:/path/to/charm malformed ``` Ensure the example scripts still function ``` $ python examples/deploy_bundle.py $ python examples/deploy_local_bundle_with_resources.py ``` Verify the following example script runs successfully: ``` from juju import jasyncio from juju.model import Model async def main(): model = Model() print('Connecting to model') await model.connect() try: 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: all(u[0].workload_status == 'active' for u in app.units)) await app.refresh(**kwargs) finally: await app.remove() await model.block_until(lambda: not len(model.applications)) if __name__ == '__main__': jasyncio.run(main()) ```
2 parents cd49a4f + 0356f00 commit 03332e6

4 files changed

Lines changed: 133 additions & 86 deletions

File tree

juju/bundle.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ def _resolve_include_file_config(self, bundle_dir):
229229

230230
return self.bundle, self.overlays
231231

232-
async def fetch_plan(self, bundle_url, origin, overlays=[]):
232+
async def fetch_plan(self, bundle, origin, overlays=[]):
233233
"""fetch_plan is called by the model.deploy(). It gathers the information about the
234234
bundle to be deployed (whether local or CharmHub), straightens it up, applies overlays
235235
if any overlays are given. Validates the bundle against known issues. Resolves and adds
@@ -245,19 +245,21 @@ async def fetch_plan(self, bundle_url, origin, overlays=[]):
245245
246246
:returns: None
247247
"""
248-
entity_id = bundle_url.path()
249-
is_local = Schema.LOCAL.matches(bundle_url.schema)
250248
bundle_dir = None
251249

252-
if is_local and os.path.isfile(entity_id):
253-
bundle_yaml = Path(entity_id).read_text()
254-
bundle_dir = Path(entity_id).parent
255-
elif is_local and os.path.isdir(entity_id):
256-
bundle_yaml = (Path(entity_id) / "bundle.yaml").read_text()
257-
bundle_dir = Path(entity_id)
250+
if is_local_bundle(str(bundle)):
251+
path = str(bundle)
252+
if path.startswith("local:"):
253+
path = path[6:]
254+
bundle_yaml, bundle_dir = read_local_bundle(path)
258255

259-
if Schema.CHARM_HUB.matches(bundle_url.schema):
260-
bundle_yaml = await self._download_bundle(bundle_url, origin)
256+
else:
257+
if client.CharmsFacade.best_facade_version(self.model.connection()) < 3:
258+
url = URL.parse(bundle, default_store=Schema.CHARM_STORE)
259+
else:
260+
url = URL.parse(bundle)
261+
path = url.path()
262+
bundle_yaml = await self._download_bundle(bundle, origin)
261263

262264
if not bundle_yaml:
263265
raise JujuError('empty bundle, nothing to deploy')
@@ -284,7 +286,7 @@ async def fetch_plan(self, bundle_url, origin, overlays=[]):
284286

285287
self.bundle = await self._validate_bundle(self.bundle)
286288

287-
if is_local:
289+
if is_local_bundle(path):
288290
self.bundle = await self._handle_local_charms(self.bundle, bundle_dir)
289291

290292
self.bundle, self.overlays = self._resolve_include_file_config(bundle_dir)
@@ -295,7 +297,7 @@ async def fetch_plan(self, bundle_url, origin, overlays=[]):
295297
yaml_data = "---\n".join(_yaml_data)
296298

297299
self.plan = await self.bundle_facade.GetChangesMapArgs(
298-
bundleurl=entity_id,
300+
bundleurl=path,
299301
yaml=yaml_data)
300302

301303
if self.plan.errors and any(self.plan.errors):
@@ -389,7 +391,6 @@ async def _resolve_charms(self):
389391
track=track,
390392
base=base,
391393
)
392-
393394
charm_url, charm_origin = await self.model._resolve_charm(charm_url, origin)
394395
spec['charm'] = str(charm_url)
395396
else:
@@ -443,6 +444,21 @@ def is_local_charm(charm_url):
443444
return charm_url.startswith('.') or charm_url.startswith('local:') or os.path.isabs(charm_url)
444445

445446

447+
is_local_bundle = is_local_charm
448+
449+
450+
def read_local_bundle(path):
451+
path = Path(path)
452+
if os.path.isfile(path):
453+
bundle_yaml = path.read_text()
454+
bundle_dir = path.parent
455+
elif os.path.isdir(path):
456+
bundle_yaml = (path / "bundle.yaml").read_text()
457+
bundle_dir = path
458+
459+
return (bundle_yaml, bundle_dir)
460+
461+
446462
async def get_charm_series(metadata, model):
447463
"""Inspects the given metadata and returns a default series from its
448464
metadata.yaml (the first item in the 'series' list).
@@ -676,12 +692,12 @@ async def run(self, context):
676692

677693
# We don't add local charms because they've already been added
678694
# by self._handle_local_charms
695+
if is_local_charm(str(self.charm)):
696+
return self.charm
697+
679698
url = URL.parse(str(self.charm))
680699
ch = None
681700
identifier = None
682-
if Schema.LOCAL.matches(url.schema):
683-
return self.charm
684-
685701
if Schema.CHARM_HUB.matches(url.schema):
686702
ch = Channel('latest', 'stable')
687703
if self.channel:
@@ -700,7 +716,7 @@ async def run(self, context):
700716
if identifier is None:
701717
raise JujuError('unknown charm {}'.format(self.charm))
702718

703-
await context.model._add_charm(identifier, origin)
719+
await context.model._add_charm(str(identifier), origin)
704720

705721
if str(ch) not in context.origins:
706722
context.origins[str(identifier)] = {}

juju/model.py

Lines changed: 48 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ class LocalDeployType:
450450
"""LocalDeployType deals with local only deployments.
451451
"""
452452

453-
async def resolve(self, url, architecture,
453+
async def resolve(self, charm_path, architecture,
454454
app_name=None, channel=None, series=None,
455455
revision=None, entity_url=None, force=False,
456456
model_conf=None):
@@ -461,36 +461,27 @@ async def resolve(self, url, architecture,
461461
-- revision flag is ignored for local charms
462462
"""
463463

464-
entity_url = url.path()
465-
entity_path = Path(entity_url)
466-
bundle_path = entity_path / 'bundle.yaml'
464+
entity_path = Path(charm_path)
465+
if entity_path.suffix == '.yaml':
466+
bundle_path = entity_path
467+
else:
468+
bundle_path = entity_path / 'bundle.yaml'
467469

468-
identifier = entity_url
469470
origin = client.CharmOrigin(source="local", architecture=architecture)
470471
if not (entity_path.is_dir() or entity_path.is_file()):
471472
raise JujuError('{} path not found'.format(entity_url))
472473

473-
is_bundle = (
474-
(entity_path.suffix == ".yaml" and entity_path.exists()) or
475-
bundle_path.exists()
476-
)
474+
is_bundle = bundle_path.exists()
477475

478476
if app_name is None:
479-
app_name = url.name
480-
481-
if not is_bundle:
482-
entity_url = url.path()
483-
entity_path = Path(entity_url)
484-
if entity_path.suffix == '.charm':
485-
with zipfile.ZipFile(str(entity_path), 'r') as charm_file:
486-
metadata = yaml.load(charm_file.read('metadata.yaml'), Loader=yaml.FullLoader)
487-
else:
488-
metadata_path = entity_path / 'metadata.yaml'
489-
metadata = yaml.load(metadata_path.read_text(), Loader=yaml.FullLoader)
490-
app_name = metadata['name']
477+
if is_bundle:
478+
bundle_with_overlays = [b for b in yaml.safe_load_all(bundle_path.read_text())]
479+
app_name = bundle_with_overlays[0].get('name', '')
480+
else:
481+
app_name = utils.get_local_charm_metadata(entity_path)["name"]
491482

492483
return DeployTypeResult(
493-
identifier=identifier,
484+
identifier=charm_path,
494485
origin=origin,
495486
app_name=app_name,
496487
is_local=True,
@@ -541,7 +532,7 @@ async def resolve(self, url, architecture,
541532
raise JujuError('revision and channel are mutually exclusive when deploying a bundle. Please choose one.')
542533

543534
if app_name is None:
544-
app_name = url.name
535+
app_name = charm_url.name
545536

546537
return DeployTypeResult(
547538
identifier=str(charm_url),
@@ -591,8 +582,8 @@ def __init__(
591582
self._charmhub = CharmHub(self)
592583

593584
self.deploy_types = {
594-
"local": LocalDeployType(),
595-
"ch": CharmhubDeployType(self._resolve_charm),
585+
Schema.LOCAL: LocalDeployType(),
586+
Schema.CHARM_HUB: CharmhubDeployType(self._resolve_charm),
596587
}
597588

598589
def is_connected(self):
@@ -1670,7 +1661,7 @@ async def deploy(
16701661
storage=None, to=None, devices=None, trust=False, attach_storage=[]):
16711662
"""Deploy a new service or bundle.
16721663
1673-
:param str entity_url: Charm or bundle url
1664+
:param str entity_url: Charm or bundle to deploy. Charm url or file path
16741665
:param str application_name: Name to give the service
16751666
:param dict bind: <charm endpoint>:<network space> pairs
16761667
:param str channel: Charm store channel from which to retrieve
@@ -1716,26 +1707,33 @@ async def deploy(
17161707
raise JujuError("Expected attach_storage to be a list of strings, given {}".format(attach_storage))
17171708

17181709
# Ensure what we pass in, is a string.
1719-
entity_url = str(entity_url)
1720-
if is_local_charm(entity_url) and not entity_url.startswith("local:"):
1721-
entity_url = "local:{}".format(entity_url)
1710+
entity = str(entity_url)
1711+
if is_local_charm(entity):
1712+
if entity.startswith("local:"):
1713+
entity = entity[6:]
1714+
architecture = await self._resolve_architecture()
1715+
schema = Schema.LOCAL
17221716

1723-
if client.CharmsFacade.best_facade_version(self.connection()) < 3:
1724-
url = URL.parse(str(entity_url), default_store=Schema.CHARM_STORE)
17251717
else:
1726-
url = URL.parse(str(entity_url))
1718+
if client.CharmsFacade.best_facade_version(self.connection()) < 3:
1719+
url = URL.parse(entity, default_store=Schema.CHARM_STORE)
1720+
else:
1721+
url = URL.parse(entity)
1722+
entity = str(url)
17271723

1728-
architecture = await self._resolve_architecture(url)
1724+
architecture = await self._resolve_architecture(url)
1725+
schema = url.schema
1726+
name = url.name
17291727

1730-
if str(url.schema) not in self.deploy_types:
1731-
raise JujuError("unknown deploy type {}, expected charmhub or local".format(url.schema))
1728+
if schema not in self.deploy_types:
1729+
raise JujuError("unknown deploy type {}, expected charmhub or local".format(schema))
17321730

17331731
model_conf = await self.get_config()
1734-
res = await self.deploy_types[str(url.schema)].resolve(url, architecture,
1735-
application_name, channel,
1736-
series, revision,
1737-
entity_url, force,
1738-
model_conf)
1732+
res = await self.deploy_types[schema].resolve(entity, architecture,
1733+
application_name, channel,
1734+
series, revision,
1735+
entity_url, force,
1736+
model_conf)
17391737

17401738
if res.identifier is None:
17411739
raise JujuError('unknown charm or bundle {}'.format(entity_url))
@@ -1750,7 +1748,7 @@ async def deploy(
17501748

17511749
if res.is_bundle:
17521750
handler = BundleHandler(self, trusted=trust, forced=force)
1753-
await handler.fetch_plan(url, charm_origin, overlays=overlays)
1751+
await handler.fetch_plan(entity, charm_origin, overlays=overlays)
17541752
await handler.execute_plan()
17551753
extant_apps = {app for app in self.applications}
17561754
pending_apps = handler.applications - extant_apps
@@ -1778,8 +1776,7 @@ async def deploy(
17781776
charm_origin)
17791777
else:
17801778
charm_origin = add_charm_res.charm_origin
1781-
if Schema.CHARM_HUB.matches(url.schema):
1782-
1779+
if Schema.CHARM_HUB.matches(schema):
17831780
if client.ApplicationFacade.best_facade_version(self.connection()) >= 19:
17841781
server_side_deploy = True
17851782
else:
@@ -1793,7 +1790,7 @@ async def deploy(
17931790
identifier,
17941791
add_charm_res.charm_origin)
17951792

1796-
is_sub = await self.charmhub.is_subordinate(url.name)
1793+
is_sub = await self.charmhub.is_subordinate(name)
17971794
if is_sub:
17981795
if num_units > 1:
17991796
raise JujuError("cannot use num_units with subordinate application")
@@ -1879,7 +1876,7 @@ async def _resolve_charm(self, url, origin, force=False, series=None, model_conf
18791876
Returns the confirmed origin returned by the Juju API to be used in
18801877
calls like ApplicationFacade.Deploy
18811878
1882-
:returns str, client.CharmOrigin, [str]
1879+
:returns url.URL, client.CharmOrigin, [str]
18831880
"""
18841881
charms_cls = client.CharmsFacade
18851882
if charms_cls.best_facade_version(self.connection()) < 3:
@@ -1919,16 +1916,18 @@ async def _resolve_charm(self, url, origin, force=False, series=None, model_conf
19191916
result.charm_origin.base = utils.get_base_from_origin_or_channel(resolved_origin, selected_series)
19201917
charm_url.series = selected_series
19211918

1922-
return str(charm_url), resolved_origin
1919+
return charm_url, resolved_origin
19231920

1924-
async def _resolve_architecture(self, url):
1921+
async def _resolve_architecture(self, url=None):
19251922
"""_resolve_architecture returns the architecture for a given charm url.
1923+
If the charm url is absent, or doesn't specific an arch, we return the
1924+
default architecture from the model.
1925+
19261926
:param str url: the client.URL to determine the arch for
19271927
19281928
:returns str architecture for the given url
19291929
"""
1930-
1931-
if url.architecture:
1930+
if url is not None and url.architecture:
19321931
return url.architecture
19331932

19341933
constraints = await self.get_constraints()

juju/url.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,11 @@ def parse(s, default_store=Schema.CHARM_HUB):
4343
if u.query != "" or u.fragment != "" or u.username or u.password:
4444
raise JujuError("charm or bundle URL {} has unrecognized parts".format(u))
4545

46-
if Schema.LOCAL.matches(u.scheme):
47-
c = URL(Schema.LOCAL, name=u.path)
48-
elif Schema.CHARM_STORE.matches(u.scheme) or \
46+
if Schema.CHARM_STORE.matches(u.scheme) or \
4947
(u.scheme == "" and Schema.CHARM_STORE.matches(default_store)):
5048
c = parse_v1_url(Schema.CHARM_STORE, u, s)
5149
else:
52-
c = parse_v2_url(u, s)
50+
c = parse_v2_url(u, s, default_store)
5351

5452
if not c or not c.schema:
5553
raise JujuError("expected schema for charm or bundle URL {}".format(u))
@@ -121,8 +119,15 @@ def parse_v1_url(schema, u, s):
121119
return c
122120

123121

124-
def parse_v2_url(u, s):
125-
c = URL(Schema.CHARM_HUB)
122+
def parse_v2_url(u, s, default_store):
123+
if not u.scheme:
124+
c = URL(default_store)
125+
elif Schema.CHARM_HUB.matches(u.scheme):
126+
c = URL(Schema.CHARM_HUB)
127+
elif Schema.LOCAL.matches(u.scheme):
128+
c = URL(Schema.LOCAL)
129+
else:
130+
raise JujuError("invalid charm url schema {}".format(u.scheme))
126131

127132
parts = u.path.split("/")
128133
num = len(parts)

0 commit comments

Comments
 (0)