Skip to content

Commit 96b8b07

Browse files
authored
Merge pull request #830 from cderici/deploy-by-revision
#830 #### Description This adds the feature that allows deploying by revision, by passing through the revision info into the `ResolveCharms` API call. Fixes #690 #### QA Steps An example is added, so it should work: ``` $ python examples/deploywithrevision.py ``` - [x] Along with that example, I need to write some integration tests for this before landing (see discussion below). ``` tox -e integration -- tests/integration/test_model.py::test_deploy_by_revision ``` ``` tox -e integration -- tests/integration/test_model.py::test_deploy_by_revision_validate_flags ``` All CI tests need to pass. #### Notes & Discussion - [x] Before landing, we need to make sure that the input validation is being handled gracefully. For regular charms, `--revision` requires `--channel`, however for bundles, `--channel` and `--revision` are mutually exclusive. The `ResolveCharms` call should be getting us the right errors for those, we just need to make sure those errors are passed through correctly. (there should be integration tests for these validations)
2 parents d809caf + b5db154 commit 96b8b07

8 files changed

Lines changed: 269 additions & 37 deletions

File tree

examples/deploy_with_revision.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from juju import jasyncio
2+
from juju.model import Model
3+
4+
5+
async def main():
6+
charm = 'juju-qa-test'
7+
8+
model = Model()
9+
print('Connecting to model')
10+
# connect to current model with current user, per Juju CLI
11+
await model.connect()
12+
13+
try:
14+
print(f'Deploying {charm} --channel 2.0/stable --revision 22')
15+
application = await model.deploy(
16+
'juju-qa-test',
17+
application_name='test',
18+
channel='2.0/stable',
19+
revision=22,
20+
)
21+
22+
print('Waiting for active')
23+
await model.wait_for_idle(status='active')
24+
25+
print(f'Removing {charm}')
26+
await application.remove()
27+
finally:
28+
print('Disconnecting from model')
29+
await model.disconnect()
30+
31+
32+
if __name__ == '__main__':
33+
jasyncio.run(main())

juju/bundle.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from . import utils, jasyncio
1818
from .origin import Channel, Source
1919
from .url import Schema, URL
20+
from .utils import get_base_from_origin_or_channel
2021

2122
log = logging.getLogger(__name__)
2223

@@ -350,11 +351,13 @@ async def _resolve_charms(self):
350351

351352
charm_url = URL.parse(spec['charm'])
352353
channel = None
353-
series = spec.get('series', None)
354+
base = None
354355
track, risk = '', ''
355356
if 'channel' in spec:
356357
channel = Channel.parse(spec['channel'])
357358
track, risk = channel.track, channel.risk
359+
series = spec.get('series', None)
360+
base = get_base_from_origin_or_channel(channel, series)
358361

359362
if self.charms_facade is not None:
360363
if cons is not None and cons['arch'] != '':
@@ -365,16 +368,18 @@ async def _resolve_charms(self):
365368
origin = client.CharmOrigin(source=Source.CHARM_HUB.value,
366369
architecture=architecture,
367370
risk=risk,
368-
track=track)
369-
if not self.model.connection().is_using_old_client and series:
370-
origin.base = client.Base(
371-
channel=utils.get_series_version(series), name='ubuntu')
372-
charm_url, charm_origin, _ = await self.model._resolve_charm(charm_url, origin)
371+
track=track,
372+
base=base,
373+
)
374+
375+
charm_url, charm_origin = await self.model._resolve_charm(charm_url, origin)
373376
spec['charm'] = str(charm_url)
374377
else:
375378
charm_origin = client.CharmOrigin(source=Source.CHARM_HUB.value,
376379
risk=risk,
377-
track=track)
380+
track=track,
381+
base=base,
382+
)
378383

379384
if str(channel) not in self.origins:
380385
self.origins[str(charm_url)] = {}
@@ -715,15 +720,13 @@ async def run(self, context):
715720
arch = self.architecture
716721
if not arch:
717722
arch = await context.model._resolve_architecture(url)
723+
base = get_base_from_origin_or_channel(ch, self.series)
718724
origin = client.CharmOrigin(source=Source.CHARM_HUB.value,
719725
architecture=arch,
720726
risk=ch.risk,
721-
track=ch.track)
722-
if not context.model.connection().is_using_old_client and self.series:
723-
origin.base = client.Base(
724-
channel=utils.get_series_version(self.series),
725-
name='ubuntu')
726-
identifier, origin, _ = await context.model._resolve_charm(url, origin)
727+
track=ch.track,
728+
base=base)
729+
identifier, origin = await context.model._resolve_charm(url, origin)
727730

728731
if identifier is None:
729732
raise JujuError('unknown charm {}'.format(self.charm))

juju/model.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -441,10 +441,15 @@ class LocalDeployType:
441441
"""LocalDeployType deals with local only deployments.
442442
"""
443443

444-
async def resolve(self, url, architecture, app_name=None, channel=None, series=None, entity_url=None):
444+
async def resolve(self, url, architecture,
445+
app_name=None, channel=None, series=None,
446+
revision=None, entity_url=None, force=False,
447+
model_conf=None):
445448
"""resolve attempts to resolve a local charm or bundle using the url
446449
and architecture. If information is missing, it will attempt to backfill
447450
that information, before sending the result back.
451+
452+
-- revision flag is ignored for local charms
448453
"""
449454

450455
entity_url = url.path()
@@ -492,11 +497,16 @@ class CharmhubDeployType:
492497
def __init__(self, charm_resolver):
493498
self.charm_resolver = charm_resolver
494499

495-
async def resolve(self, url, architecture, app_name=None, channel=None, series=None, entity_url=None):
500+
async def resolve(self, url, architecture,
501+
app_name=None, channel=None, series=None,
502+
revision=None, entity_url=None, force=False,
503+
model_conf=None):
496504
"""resolve attempts to resolve charmhub charms or bundles. A request to
497505
the charmhub API is required to correctly determine the charm url and
498506
underlying origin.
499507
"""
508+
if revision and not channel:
509+
raise JujuError('specifying a revision requires a channel for future upgrades. Please use --channel')
500510

501511
ch = Channel('latest', 'stable')
502512
if channel is not None:
@@ -511,26 +521,23 @@ async def resolve(self, url, architecture, app_name=None, channel=None, series=N
511521
risk=ch.risk,
512522
track=ch.track,
513523
base=base,
524+
revision=revision,
514525
)
515526

516-
charm_url_str, origin, supported_series = await self.charm_resolver(url, origin)
517-
charm_url = URL.parse(charm_url_str)
527+
charm_url, origin = await self.charm_resolver(url, origin, force, series, model_conf)
528+
529+
is_bundle = origin.type_ == "bundle"
530+
if is_bundle and revision and channel:
531+
raise JujuError('revision and channel are mutually exclusive when deploying a bundle. Please choose one.')
518532

519533
if app_name is None:
520534
app_name = url.name
521535

522-
if series:
523-
if series in supported_series:
524-
origin.series = series
525-
charm_url.series = series
526-
else:
527-
raise JujuError("Series {} not supported for {}. Only {}".format(series, url, supported_series))
528-
529536
return DeployTypeResult(
530537
identifier=str(charm_url),
531538
app_name=app_name,
532539
origin=origin,
533-
is_bundle=origin.type_ == "bundle",
540+
is_bundle=is_bundle,
534541
)
535542

536543

@@ -1611,7 +1618,7 @@ async def debug_log(
16111618
async def deploy(
16121619
self, entity_url, application_name=None, bind=None,
16131620
channel=None, config=None, constraints=None, force=False,
1614-
num_units=1, overlays=[], base=None, resources=None, series=None,
1621+
num_units=1, overlays=[], base=None, resources=None, series=None, revision=None,
16151622
storage=None, to=None, devices=None, trust=False, attach_storage=[]):
16161623
"""Deploy a new service or bundle.
16171624
@@ -1630,6 +1637,8 @@ async def deploy(
16301637
:param str base: The base on which to deploy
16311638
:param dict resources: <resource name>:<file path> pairs
16321639
:param str series: Series on which to deploy DEPRECATED: use --base (with Juju 3.1)
1640+
:param int revision: specifying a revision requires a channel for future upgrades for charms.
1641+
For bundles, revision and channel are mutually exclusive.
16331642
:param dict storage: Storage constraints TODO how do these look?
16341643
:param to: Placement directive as a string. For example:
16351644
@@ -1675,7 +1684,12 @@ async def deploy(
16751684
if str(url.schema) not in self.deploy_types:
16761685
raise JujuError("unknown deploy type {}, expected charmhub or local".format(url.schema))
16771686

1678-
res = await self.deploy_types[str(url.schema)].resolve(url, architecture, application_name, channel, series, entity_url)
1687+
model_conf = await self.get_config()
1688+
res = await self.deploy_types[str(url.schema)].resolve(url, architecture,
1689+
application_name, channel,
1690+
series, revision,
1691+
entity_url, force,
1692+
model_conf)
16791693

16801694
if res.identifier is None:
16811695
raise JujuError('unknown charm or bundle {}'.format(entity_url))
@@ -1772,6 +1786,7 @@ async def deploy(
17721786
devices=devices,
17731787
charm_origin=charm_origin,
17741788
attach_storage=attach_storage,
1789+
force=force,
17751790
)
17761791

17771792
async def _add_charm(self, charm_url, origin):
@@ -1785,7 +1800,7 @@ async def _add_charm(self, charm_url, origin):
17851800
client_facade = client.ClientFacade.from_connection(self.connection())
17861801
return await client_facade.AddCharm(channel=str(origin.risk), url=charm_url, force=False)
17871802

1788-
async def _resolve_charm(self, url, origin):
1803+
async def _resolve_charm(self, url, origin, force=False, series=None, model_config=None):
17891804
"""Calls Charms.ResolveCharms to resolve all the fields of the
17901805
charm_origin and also the url and the supported_series
17911806
@@ -1812,7 +1827,8 @@ async def _resolve_charm(self, url, origin):
18121827

18131828
resolve_origin = {'source': source, 'architecture': origin.architecture,
18141829
'track': origin.track, 'risk': origin.risk,
1815-
'base': origin.base}
1830+
'base': origin.base, 'revision': origin.revision,
1831+
}
18161832

18171833
resp = await charms_facade.ResolveCharms(resolve=[{
18181834
'reference': str(url),
@@ -1825,7 +1841,16 @@ async def _resolve_charm(self, url, origin):
18251841
if result.error:
18261842
raise JujuError(result.error.message)
18271843

1828-
return (result.url, result.charm_origin, result.supported_series)
1844+
supported_series = result.supported_series
1845+
resolved_origin = result.charm_origin
1846+
charm_url = URL.parse(result.url)
1847+
1848+
# run the series selector to get a series for the base
1849+
selected_series = utils.series_selector(series, url, model_config, supported_series, force)
1850+
result.charm_origin.base = utils.get_base_from_origin_or_channel(resolved_origin, selected_series)
1851+
charm_url.series = selected_series
1852+
1853+
return result.url, result.charm_origin
18291854

18301855
async def _resolve_architecture(self, url):
18311856
if url.architecture:
@@ -1956,7 +1981,8 @@ async def add_local_resources(self, application, entity_url, metadata, resources
19561981
async def _deploy(self, charm_url, application, series, config,
19571982
constraints, endpoint_bindings, resources, storage,
19581983
channel=None, num_units=None, placement=None,
1959-
devices=None, charm_origin=None, attach_storage=[]):
1984+
devices=None, charm_origin=None, attach_storage=[],
1985+
force=False):
19601986
"""Logic shared between `Model.deploy` and `BundleHandler.deploy`.
19611987
"""
19621988
log.info('Deploying %s', charm_url)
@@ -1984,6 +2010,7 @@ async def _deploy(self, charm_url, application, series, config,
19842010
placement=placement,
19852011
devices=devices,
19862012
attach_storage=attach_storage,
2013+
force=force,
19872014
)
19882015
result = await app_facade.Deploy(applications=[app])
19892016
errors = [r.error.message for r in result.results if r.error]

juju/utils.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from . import jasyncio, origin, errors
1313
from .client import client
14+
from .errors import JujuError
1415

1516

1617
async def execute_process(*cmd, log=None):
@@ -412,7 +413,104 @@ def parse_base_arg(base):
412413
"""
413414
client.CharmBase()
414415
if type(base) != str or "@" not in base:
415-
raise errors.JujuError("expected base string to contain os and channel separated by '@'")
416+
raise errors.JujuError(f"expected base string to contain os and channel separated by '@', got : {base}")
416417

417418
name, channel = base.split('@')
418419
return client.Base(name=name, channel=channel)
420+
421+
422+
DEFAULT_SUPPORTED_LTS = 'jammy'
423+
DEFAULT_SUPPORTED_LTS_BASE = client.Base(channel='22.04', name='ubuntu')
424+
425+
426+
def base_channel_from_series(track, risk, series=None):
427+
return origin.Channel(track=track, risk=risk).normalize().compute_base_channel(series=series)
428+
429+
430+
def get_os_from_series(series=None):
431+
if not series or series in UBUNTU_SERIES:
432+
return 'ubuntu'
433+
raise JujuError(f'os for the series {series} needs to be added')
434+
435+
436+
def get_base_from_origin_or_channel(origin_or_channel, series=None):
437+
channel = base_channel_from_series(origin_or_channel.track, origin_or_channel.risk, series)
438+
os_name = get_os_from_series(series)
439+
return client.Base(channel=channel, name=os_name)
440+
441+
442+
def series_for_charm(requested_series, supported_series):
443+
"""series_for_charm takes a requested series and a list of series supported by a
444+
charm and returns the series which is relevant.
445+
If the requested series is empty, then the first supported series is used,
446+
otherwise the requested series is validated against the supported series.
447+
"""
448+
if len(supported_series) == 1 and supported_series[0] == '':
449+
raise JujuError("invalid supported series reported by charm : ['']")
450+
if len(supported_series) == 0:
451+
if requested_series == '':
452+
raise JujuError("missing series")
453+
return requested_series
454+
455+
# use the charm default
456+
if requested_series == '':
457+
return supported_series[-1]
458+
459+
for s in supported_series:
460+
if requested_series == s:
461+
return requested_series
462+
raise JujuError(f'requested series {requested_series} is not among the supported series {supported_series}')
463+
464+
465+
def user_requested(series_arg, supported_series, force):
466+
series = series_for_charm(series_arg, supported_series)
467+
if force:
468+
series = series_arg
469+
# Todo (cderici): validate the series with workload_series to see if juju is supporting that
470+
return series
471+
472+
473+
def series_selector(series_arg='', charm_url=None, model_config=None, supported_series=[], force=False):
474+
"""
475+
series_selector corresponds to the CharmSeries() in
476+
https://github.com/juju/juju/blob/develop/core/charm/series_selector.go
477+
478+
determines what series to use with a charm.
479+
Order of preference is:
480+
- user requested with --series or defined by bundle when deploying
481+
- user requested in charm's url (e.g. juju deploy jammy/ubuntu)
482+
- model default, if set, acts like --series
483+
- default from charm metadata supported series / series in url
484+
- default LTS
485+
"""
486+
487+
# User has requested a series with --series.
488+
if series_arg:
489+
return user_requested(series_arg, supported_series, force)
490+
491+
# User specified a series in the charm URL, e.g.
492+
# juju deploy precise/ubuntu.
493+
if charm_url and charm_url.series:
494+
return user_requested(charm_url.series, supported_series, force)
495+
496+
# No series explicitly requested by the user.
497+
# Use model default series, if explicitly set and supported by the charm.
498+
if model_config and model_config['default-base'].value:
499+
default_base = model_config['default-base'].value
500+
base = parse_base_arg(default_base)
501+
series = base_channel_to_series(base.channel)
502+
return user_requested(series, supported_series, force)
503+
504+
# Next fall back to the charm's list of series, filtered to what's supported
505+
# by Juju. Preserve the order of the supported series from the charm
506+
# metadata, as the order could be out of order compared to Ubuntu series
507+
# order (precise, xenial, bionic, trusty, etc).
508+
try:
509+
# TODO (cderici): restrict the supported_series with JujuSupportedSeries
510+
return user_requested('', supported_series, force)
511+
except JujuError:
512+
pass
513+
514+
# Charm hasn't specified a default (likely due to being a local charm
515+
# deployed by path). Last chance, best we can do is default to LTS.
516+
return DEFAULT_SUPPORTED_LTS

tests/integration/bundle/test-overlays/bundle-with-overlay-multi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ applications:
55
num_units: 1
66
mysql:
77
charm: "mysql"
8-
channel: candidate
8+
channel: 8.0/stable
99
num_units: 1
1010
relations:
1111
- ["ghost", "mysql"]

0 commit comments

Comments
 (0)