Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deploy by revision on 2.9 #957

Merged
merged 10 commits into from
Oct 13, 2023
73 changes: 73 additions & 0 deletions examples/annotate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/python3

# Copyright 2023 Canonical Ltd.
# Licensed under the Apache V2, see LICENCE file for details.

import asyncio
import argparse
import logging
import sys

# from juju import jasyncio
import juju.model


async def get_annotations(model, args):
logging.info('getting annotations')
annotations = await model.get_annotations()
if args.key is None:
for key, value in annotations.items():
print(f'{key}: {value!r}')
else:
value = annotations[args.key]
print(value)


async def set_annotation(model, args):
logging.info(f'setting annotation for {args.key!r}')
await model.set_annotations({args.key: args.value})


async def get_model():
logging.info('getting model')
model = juju.model.Model()
await model.connect()
return model


async def run_func(args):
model = await get_model()
try:
await args.func(model, args)
finally:
logging.info('disconnecting the model')
await model.disconnect()


def parse_args(args):
p = argparse.ArgumentParser()
p.add_argument('--debug', action="store_const", dest="loglevel", const=logging.DEBUG, default=logging.WARNING)
p.add_argument('--verbose', '-v', action="store_const", dest="loglevel", const=logging.INFO)
p.add_argument('--quiet', action="store_const", dest="loglevel", const=logging.CRITICAL)
sub = p.add_subparsers()
getp = sub.add_parser('get')
getp.set_defaults(func=get_annotations)
getp.add_argument("key", help='the key to set', nargs='?')
setp = sub.add_parser('set')
setp.set_defaults(func=set_annotation)
setp.add_argument("key", help='the key to set')
setp.add_argument("value", help='set the value of key to this value, empty string removes the annotation')
return p.parse_args(args)


def main():
args = parse_args(sys.argv[1:])
rootLogger = logging.getLogger()
rootLogger.setLevel(args.loglevel)
asyncio.run(run_func(args))


if __name__ == '__main__':
main()

# vim: expandtab ts=4 sts=4 shiftwidth=4 autoindent:
34 changes: 34 additions & 0 deletions examples/deploy_with_revision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from juju import jasyncio
from juju.model import Model


async def main():
charm = 'juju-qa-test'

model = Model()
print('Connecting to model')
# connect to current model with current user, per Juju CLI
await model.connect()

try:
print(f'Deploying {charm} --channel latest/edge --revision 19')
application = await model.deploy(
'juju-qa-test',
application_name='test',
channel='latest/edge',
series='xenial',
revision=19,
)

print('Waiting for active')
await model.wait_for_idle(status='active')

print(f'Removing {charm}')
await application.remove()
finally:
print('Disconnecting from model')
await model.disconnect()


if __name__ == '__main__':
jasyncio.run(main())
32 changes: 24 additions & 8 deletions juju/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,8 @@ class LocalDeployType:
"""LocalDeployType deals with local only deployments.
"""

async def resolve(self, url, architecture, app_name=None, channel=None, series=None, entity_url=None, force=False):
async def resolve(self, url, 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.
Expand Down Expand Up @@ -509,7 +510,7 @@ def _default_app_name(meta):
suggested_name = meta.get('charm-metadata', {}).get('Name')
return suggested_name or meta.get('id', {}).get('Name')

async def resolve(self, url, architecture, app_name=None, channel=None, series=None, entity_url=None, force=False):
async def resolve(self, url, architecture, app_name=None, channel=None, series=None, revision=None, entity_url=None, force=False):
"""resolve attempts to resolve charmstore charms or bundles. A request
to the charmstore is required to get more information about the
underlying identifier.
Expand Down Expand Up @@ -548,11 +549,13 @@ class CharmhubDeployType:
def __init__(self, charm_resolver):
self.charm_resolver = charm_resolver

async def resolve(self, url, architecture, app_name=None, channel=None, series=None, entity_url=None, force=False):
async def resolve(self, url, architecture, app_name=None, channel=None, series=None, revision=None, entity_url=None, force=False):
"""resolve attempts to resolve charmhub charms or bundles. A request to
the charmhub API is required to correctly determine the charm url and
underlying origin.
"""
if revision and not channel:
raise JujuError('specifying a revision requires a channel for future upgrades. Please use --channel')

ch = Channel('latest', 'stable')
if channel is not None:
Expand All @@ -562,9 +565,17 @@ async def resolve(self, url, architecture, app_name=None, channel=None, series=N
architecture=architecture,
risk=ch.risk,
track=ch.track,
series=series)
series=series,
revision=revision,
)

charm_url_str, origin = await self.charm_resolver(url, origin, force)

charm_url, origin = await self.charm_resolver(url, origin, force)
is_bundle = origin.type_ == "bundle"
if is_bundle and revision and channel:
raise JujuError('revision and channel are mutually exclusive when deploying a bundle. Please choose one.')

charm_url = URL.parse(charm_url_str)

if app_name is None:
app_name = url.name
Expand All @@ -573,7 +584,7 @@ async def resolve(self, url, architecture, app_name=None, channel=None, series=N
identifier=str(charm_url),
app_name=app_name,
origin=origin,
is_bundle=origin.type_ == "bundle",
is_bundle=is_bundle,
)


Expand Down Expand Up @@ -1588,7 +1599,7 @@ def _get_series(self, entity_url, entity):
async def deploy(
self, entity_url, application_name=None, bind=None,
channel=None, config=None, constraints=None, force=False,
num_units=1, overlays=[], plan=None, resources=None, series=None,
num_units=1, overlays=[], base=None, resources=None, series=None, revision=None,
storage=None, to=None, devices=None, trust=False):
"""Deploy a new service or bundle.

Expand All @@ -1607,6 +1618,8 @@ async def deploy(
:param str plan: Plan under which to deploy charm
:param dict resources: <resource name>:<file path> pairs
:param str series: Series on which to deploy
:param int revision: specifying a revision requires a channel for future upgrades for charms.
For bundles, revision and channel are mutually exclusive.
:param dict storage: Storage constraints TODO how do these look?
:param to: Placement directive as a string. For example:

Expand Down Expand Up @@ -1647,7 +1660,9 @@ async def deploy(
if str(url.schema) not in self.deploy_types:
raise JujuError("unknown deploy type {}, expected charmhub, charmstore or local".format(url.schema))

res = await self.deploy_types[str(url.schema)].resolve(url, architecture, application_name, channel, series, entity_url, force)
res = await self.deploy_types[str(url.schema)].resolve(url, architecture,
application_name, channel, series,
revision, entity_url, force)

if res.identifier is None:
raise JujuError('unknown charm or bundle {}'.format(entity_url))
Expand Down Expand Up @@ -1782,6 +1797,7 @@ async def _resolve_charm(self, url, origin, force=False):
'architecture': origin.architecture,
'track': origin.track,
'risk': origin.risk,
'revision': origin.revision,
}
}])
if len(resp.results) != 1:
Expand Down
35 changes: 34 additions & 1 deletion tests/integration/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import pylxd
import pytest
from juju import jasyncio
from juju import jasyncio, url
from juju.client.client import ApplicationFacade, ConfigValue
from juju.errors import JujuError, JujuUnitError, JujuConnectionError
from juju.model import Model, ModelObserver
Expand Down Expand Up @@ -88,6 +88,39 @@ async def test_deploy_bundle_local_resource_relative_path(event_loop):
timeout=60 * 4)


@base.bootstrapped
@pytest.mark.asyncio
async def test_deploy_by_revision(event_loop):
async with base.CleanModel() as model:
app = await model.deploy('juju-qa-test',
application_name='test',
channel='2.0/stable',
series='xenial',
revision=19,)

assert url.URL.parse(app.charm_url).revision == 19


@base.bootstrapped
@pytest.mark.asyncio
async def test_deploy_by_revision_validate_flags(event_loop):
# Make sure we fail gracefully when invalid --revision/--channel
# flags are used

async with base.CleanModel() as model:
# For charms --revision requires --channel
with pytest.raises(JujuError):
await model.deploy('juju-qa-test',
# channel='2.0/stable',
revision=22)

# For bundles, --revision and --channel are mutually exclusive
with pytest.raises(JujuError):
await model.deploy('ch:canonical-livepatch-onprem',
channel='latest/stable',
revision=4)


@base.bootstrapped
@pytest.mark.asyncio
async def test_deploy_local_bundle_include_file(event_loop):
Expand Down