Skip to content

Commit aad7761

Browse files
Allow the deploying of charms and bundles
This is still a work in progress, but essentially charms without resources are able to be deployed and bundle machines are. There is an issue with a missing charm revision for charmhub charms, I'm unsure where to resolve that currently.
1 parent 1a0dc2f commit aad7761

4 files changed

Lines changed: 200 additions & 47 deletions

File tree

juju/bundle.py

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import asyncio
22
import logging
3+
import io
34
import os
45
import zipfile
6+
import requests
7+
from contextlib import closing
58
from pathlib import Path
69

710
import yaml
@@ -11,6 +14,8 @@
1114
from .client import client
1215
from .constraints import parse as parse_constraints, parse_storage_constraint, parse_device_constraint
1316
from .errors import JujuError
17+
from .origin import Channel, Risk
18+
from .url import Schema, URL
1419

1520
log = logging.getLogger(__name__)
1621

@@ -128,22 +133,28 @@ async def _handle_local_charms(self, bundle, bundle_dir):
128133

129134
return bundle
130135

131-
async def fetch_plan(self, entity_id):
132-
is_store_url = entity_id.startswith('cs:')
133-
is_local = False
136+
async def fetch_plan(self, charm_url, origin):
137+
entity_id = charm_url.path()
138+
is_local = Schema.LOCAL.matches(charm_url.schema)
134139
bundle_dir = None
135140

136-
if not is_store_url and os.path.isfile(entity_id):
141+
if is_local and os.path.isfile(entity_id):
137142
bundle_yaml = Path(entity_id).read_text()
138-
is_local = True
139143
bundle_dir = Path(entity_id).parent
140-
elif not is_store_url and os.path.isdir(entity_id):
144+
elif is_local and os.path.isdir(entity_id):
141145
bundle_yaml = (Path(entity_id) / "bundle.yaml").read_text()
142146
bundle_dir = Path(entity_id)
143-
else:
147+
148+
if Schema.CHARM_STORE.matches(charm_url.schema):
144149
bundle_yaml = await self.charmstore.files(entity_id,
145150
filename='bundle.yaml',
146151
read_file=True)
152+
elif Schema.CHARM_HUB.matches(charm_url.schema):
153+
bundle_yaml = await self._download_bundle(charm_url, origin)
154+
155+
if not bundle_yaml:
156+
raise JujuError('empty bundle, nothing to deploy')
157+
147158
self.bundle = yaml.safe_load(bundle_yaml)
148159
self.bundle = await self._validate_bundle(self.bundle)
149160
if is_local:
@@ -156,6 +167,46 @@ async def fetch_plan(self, entity_id):
156167
if self.plan.errors:
157168
raise JujuError(self.plan.errors)
158169

170+
async def _download_bundle(self, charm_url, origin):
171+
charms_cls = client.CharmsFacade
172+
if charms_cls.best_facade_version(self.model.connection()) > 2:
173+
charms_facade = charms_cls.from_connection(self.model.connection())
174+
resp = await charms_facade.GetDownloadInfos(entities=[{
175+
'charm-url': str(charm_url),
176+
'charm-origin': {
177+
'source': origin.source,
178+
'type': origin.type_,
179+
'id': origin.id_,
180+
'hash': origin.hash_,
181+
'revision': origin.revision,
182+
'risk': origin.risk,
183+
'track': origin.track,
184+
'architecture': origin.architecture,
185+
'os': origin.os,
186+
'series': origin.series,
187+
}
188+
}])
189+
if len(resp.results) != 1:
190+
raise JujuError("expected one result, received {}".format(resp.results))
191+
192+
result = resp.results[0]
193+
if not result.url:
194+
raise JujuError("no url found for bundle {}".format(charm_url.name))
195+
196+
bundle_resp = requests.get(result.url)
197+
bundle_resp.raise_for_status()
198+
199+
with closing(bundle_resp), zipfile.ZipFile(io.BytesIO(bundle_resp.content)) as archive:
200+
return self._get_bundle_yaml(archive)
201+
202+
raise JujuError('charm facade not supported')
203+
204+
def _get_bundle_yaml(self, archive):
205+
for member in archive.infolist():
206+
if member.filename == "bundle.yaml":
207+
return archive.read(member)
208+
raise JujuError("bundle.yaml not found")
209+
159210
async def execute_plan(self):
160211
changes = ChangeSet(self.plan.changes)
161212
for step in changes.sorted():
@@ -338,11 +389,14 @@ async def run(self, context):
338389
if context.model.info.agent_version < client.Number.from_json('2.4.0'):
339390
raise NotImplementedError("trusted is not supported on model version {}".format(context.model.info.agent_version))
340391
options["trust"] = "true"
341-
if not charm.startswith('local:'):
392+
393+
url = URL.parse(str(charm))
394+
if Schema.CHARM_STORE.matches(url.schema):
342395
resources = await context.model._add_store_resources(
343396
self.application, charm, overrides=self.resources)
344397
else:
345398
resources = {}
399+
346400
await context.model._deploy(
347401
charm_url=charm,
348402
application=self.application,
@@ -404,6 +458,10 @@ def __init__(self, change_id, requires, params=None):
404458
self.channel = params[2]
405459
else:
406460
self.channel = None
461+
if len(params) > 3 and params[3] != "":
462+
self.architecture = params[3]
463+
else:
464+
self.architecture = None
407465
elif isinstance(params, dict):
408466
AddCharmChange.from_dict(self, params)
409467
else:
@@ -422,15 +480,38 @@ async def run(self, context):
422480
:param context: is used for any methods or properties required to
423481
perform a change.
424482
"""
483+
425484
# We don't add local charms because they've already been added
426485
# by self._handle_local_charms
427-
if self.charm.startswith('local:'):
486+
url = URL.parse(str(self.charm))
487+
identifier = None
488+
if Schema.LOCAL.matches(url.schema):
428489
return self.charm
429490

430-
entity_id = await context.charmstore.entityId(self.charm)
431-
log.debug('Adding %s', entity_id)
432-
await context.client_facade.AddCharm(channel=None, url=entity_id, force=False)
433-
return entity_id
491+
elif Schema.CHARM_STORE.matches(url.schema):
492+
entity_id = await context.charmstore.entityId(self.charm)
493+
log.debug('Adding %s', entity_id)
494+
await context.client_facade.AddCharm(channel=None, url=entity_id, force=False)
495+
identifier = entity_id
496+
497+
elif Schema.CHARM_HUB.matches(url.schema):
498+
ch = Channel('latest', 'stable')
499+
if self.channel:
500+
ch = Channel.parse(self.channel).normalize()
501+
arch = self.architecture
502+
if not arch:
503+
arch = await context.model._resolve_architecture(url)
504+
origin = client.CharmOrigin(source="charm-hub",
505+
architecture=arch,
506+
risk=ch.risk,
507+
track=ch.track)
508+
identifier, origin = await context.model._resolve_charm(url, origin)
509+
510+
if identifier is None:
511+
raise JujuError('unknown charm {}'.format(self.charm))
512+
513+
await context.model._add_charm(identifier, origin)
514+
return url.path()
434515

435516
def __str__(self):
436517
series = ""

juju/client/connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
'Bundle': {'versions': [1, 2, 3]},
3232
'CharmHub': {'versions': [1]},
3333
'CharmRevisionUpdater': {'versions': [2]},
34-
'Charms': {'versions': [2]},
34+
'Charms': {'versions': [2, 3, 4]},
3535
'Cleaner': {'versions': [2]},
3636
'Client': {'versions': [1, 2]},
3737
'Cloud': {'versions': [1, 2, 3, 4, 5]},

juju/model.py

Lines changed: 102 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .names import is_valid_application
3636
from .offerendpoints import ParseError as OfferParseError
3737
from .offerendpoints import parse_local_endpoint, parse_offer_url
38+
from .origin import Channel, Risk
3839
from .placement import parse as parse_placement
3940
from .tag import application as application_tag
4041
from .url import URL, Schema
@@ -1424,34 +1425,67 @@ async def deploy(
14241425
is_local = False
14251426
is_bundle = False
14261427
identifier = None
1428+
origin = None
1429+
result = None
1430+
14271431
url = URL.parse(str(entity_url))
1428-
if url.schema.matches(Schema.Local):
1432+
architecture = await self._resolve_architecture(url)
1433+
1434+
if Schema.LOCAL.matches(url.schema):
14291435
entity_url = url.path()
14301436
entity_path = Path(entity_url)
14311437
bundle_path = entity_path / 'bundle.yaml'
14321438

14331439
identifier = entity_url
1434-
is_local = (
1435-
entity_path.is_dir() or
1436-
entity_path.is_file()
1437-
)
1440+
origin = client.CharmOrigin(source="local", architecture=architecture)
1441+
if not (entity_path.is_dir() or entity_path.is_file()):
1442+
raise JujuError('{} path not found'.format(entity_url))
1443+
14381444
is_bundle = (
14391445
(entity_url.endswith(".yaml") and entity_path.exists()) or
14401446
bundle_path.exists()
14411447
)
1442-
elif url.schema.matches(Schema.CHARM_STORE):
1443-
charm = await self.charmstore.entity(str(url), channel=channel,
1448+
1449+
elif Schema.CHARM_STORE.matches(url.schema):
1450+
result = await self.charmstore.entity(str(url),
1451+
channel=channel,
14441452
include_stats=False)
1445-
identifier = charm['Id']
1453+
identifier = result['Id']
1454+
origin = client.CharmOrigin(source="charm-store",
1455+
architecture=architecture,
1456+
risk=channel)
14461457
is_bundle = url.series == "bundle"
1447-
elif url.schema.matches(Schema.CHARM_HUB):
1448-
# TODO (stickupkid): request to get the charm id.
1449-
1450-
1458+
if not series:
1459+
series = self._get_series(entity_url, result)
1460+
1461+
elif Schema.CHARM_HUB.matches(url.schema):
1462+
ch = Channel('latest', 'stable')
1463+
if channel:
1464+
ch = Channel.parse(channel).normalize()
1465+
origin = client.CharmOrigin(source="charm-hub",
1466+
architecture=architecture,
1467+
risk=ch.risk,
1468+
track=ch.track)
1469+
charm_url, origin = await self._resolve_charm(url, origin)
1470+
1471+
identifier = charm_url
1472+
is_bundle = origin.type_ == "bundle"
1473+
1474+
if identifier is None:
1475+
raise JujuError('unknown charm or bundle {}'.format(entity_url))
1476+
1477+
if not application_name:
1478+
if Schema.LOCAL.matches(url.schema) and Schema.CHARM_STORE.matches(url.schema):
1479+
application_name = result['Meta']['charm-metadata']['Name']
1480+
else:
1481+
# For charmhub charms, we don't have the metadata and we're not
1482+
# going to get it, so fallback to the url and use that one if a
1483+
# user didn't specify it.
1484+
application_name = url.name
14511485

14521486
if is_bundle:
14531487
handler = BundleHandler(self, trusted=trust, forced=force)
1454-
await handler.fetch_plan(entity_id)
1488+
await handler.fetch_plan(url, origin)
14551489
await handler.execute_plan()
14561490
extant_apps = {app for app in self.applications}
14571491
pending_apps = set(handler.applications) - extant_apps
@@ -1467,42 +1501,47 @@ async def deploy(
14671501
return [app for name, app in self.applications.items()
14681502
if name in handler.applications]
14691503
else:
1504+
# XXX: we're dropping local resources here, but we don't
1505+
# actually support them yet anyway
14701506
if not is_local:
1471-
if not application_name:
1472-
application_name = entity['Meta']['charm-metadata']['Name']
1473-
if not series:
1474-
series = self._get_series(entity_url, entity)
1475-
1476-
self._add_charm(channel, entity_id)
1477-
# XXX: we're dropping local resources here, but we don't
1478-
# actually support them yet anyway
1479-
resources = await self._add_store_resources(application_name,
1480-
entity_id,
1481-
entity=entity)
1507+
await self._add_charm(identifier, origin)
1508+
1509+
# TODO (stickupkid): Handle charmhub charms, for now we'll only
1510+
# handle charmstore charms.
1511+
if Schema.CHARM_STORE.matches(url.schema):
1512+
resources = await self._add_store_resources(application_name,
1513+
identifier,
1514+
entity=result)
14821515
else:
14831516
if not application_name:
1517+
entity_url = url.path()
1518+
entity_path = Path(entity_url)
14841519
if str(entity_path).endswith('.charm'):
14851520
with zipfile.ZipFile(entity_path, 'r') as charm_file:
14861521
metadata = yaml.load(charm_file.read('metadata.yaml'), Loader=yaml.FullLoader)
14871522
else:
1523+
metadata_path = entity_path / 'metadata.yaml'
14881524
metadata = yaml.load(metadata_path.read_text(), Loader=yaml.FullLoader)
14891525
application_name = metadata['name']
1526+
14901527
# We have a local charm dir that needs to be uploaded
14911528
charm_dir = os.path.abspath(
1492-
os.path.expanduser(entity_id))
1529+
os.path.expanduser(identifier))
14931530
series = series or get_charm_series(charm_dir)
14941531
if not series:
14951532
raise JujuError(
14961533
"Couldn't determine series for charm at {}. "
14971534
"Pass a 'series' kwarg to Model.deploy().".format(
14981535
charm_dir))
1499-
entity_id = await self.add_local_charm_dir(charm_dir, series)
1536+
identifier = await self.add_local_charm_dir(charm_dir, series)
1537+
15001538
if config is None:
15011539
config = {}
15021540
if trust:
15031541
config["trust"] = "true"
1542+
15041543
return await self._deploy(
1505-
charm_url=entity_id,
1544+
charm_url=identifier,
15061545
application=application_name,
15071546
series=series,
15081547
config=config,
@@ -1516,16 +1555,49 @@ async def deploy(
15161555
devices=devices,
15171556
)
15181557

1519-
async def _add_charm(self, channel, entity_id):
1558+
async def _add_charm(self, charm_url, origin):
15201559
# client facade is deprecated with in Juju, and smaller, more focused
15211560
# facades have been created and we'll use that if it's available.
15221561
charms_cls = client.CharmsFacade
15231562
if charms_cls.best_facade_version(self.connection()) > 2:
15241563
charms_facade = charms_cls.from_connection(self.connection())
1525-
return await charms_facade.AddCharm(channel=channel, url=entity_id, force=False)
1564+
return await charms_facade.AddCharm(charm_origin=origin, url=charm_url, force=False)
15261565

15271566
client_facade = client.ClientFacade.from_connection(self.connection())
1528-
await client_facade.AddCharm(channel=channel, url=entity_id, force=False)
1567+
await client_facade.AddCharm(channel=origin.channel, url=charm_url, force=False)
1568+
1569+
async def _resolve_charm(self, url, origin):
1570+
charms_cls = client.CharmsFacade
1571+
if charms_cls.best_facade_version(self.connection()) < 3:
1572+
raise JujuError("resolve charm")
1573+
1574+
charms_facade = charms_cls.from_connection(self.connection())
1575+
1576+
resp = await charms_facade.ResolveCharms(resolve=[{
1577+
'reference': str(url),
1578+
'charm-origin': {
1579+
'source': 'charm-hub',
1580+
'architecture': origin.architecture,
1581+
}
1582+
}])
1583+
if len(resp.results) != 1:
1584+
raise JujuError("expected one result, received {}".format(resp.results))
1585+
1586+
result = resp.results[0]
1587+
if result.error:
1588+
raise JujuError(result.error.message)
1589+
1590+
return (result.url, result.charm_origin)
1591+
1592+
async def _resolve_architecture(self, url):
1593+
if url.architecture:
1594+
return url.architecture
1595+
1596+
constraints = await self.get_constraints()
1597+
if 'arch' in constraints:
1598+
return constraints['arch']
1599+
1600+
return "amd64"
15291601

15301602
async def _add_store_resources(self, application, entity_url,
15311603
overrides=None, entity=None):

0 commit comments

Comments
 (0)