X-Git-Url: https://osm.etsi.org/gitweb/?a=blobdiff_plain;f=juju%2Fmodel.py;h=6eb4e9fe1a0b6f06294d8046d540ac22f1cc3189;hb=e42708b6ce89b35b88ae526065951f994128ceed;hp=df72eb22cae2f78fd90edcd0609495e41b7044aa;hpb=df3aef1ed7daac491bcc135c33e9dfc3b5cd4eae;p=osm%2FN2VC.git diff --git a/juju/model.py b/juju/model.py index df72eb2..6eb4e9f 100644 --- a/juju/model.py +++ b/juju/model.py @@ -1,10 +1,16 @@ import asyncio import collections +import json import logging +import os import re +import stat +import tempfile import weakref +import zipfile from concurrent.futures import CancelledError from functools import partial +from pathlib import Path import yaml from theblues import charmstore @@ -12,11 +18,11 @@ from theblues import charmstore from .client import client from .client import watcher from .client import connection -from .constraints import parse as parse_constraints +from .constraints import parse as parse_constraints, normalize_key from .delta import get_entity_delta from .delta import get_entity_class from .exceptions import DeadEntityException -from .errors import JujuAPIError +from .errors import JujuError, JujuAPIError log = logging.getLogger(__name__) @@ -72,6 +78,14 @@ class ModelObserver(object): await method(delta, old, new, model) async def on_change(self, delta, old, new, model): + """Generic model-change handler. + + :param delta: :class:`juju.client.overrides.Delta` + :param old: :class:`juju.model.ModelEntity` + :param new: :class:`juju.model.ModelEntity` + :param model: :class:`juju.model.Model` + + """ pass @@ -205,18 +219,16 @@ class ModelEntity(object): self.connected = connected self.connection = model.connection + def __repr__(self): + return '<{} entity_id="{}">'.format(type(self).__name__, + self.entity_id) + def __getattr__(self, name): """Fetch object attributes from the underlying data dict held in the model. """ - if self.data is None: - raise DeadEntityException( - "Entity {}:{} is dead - its attributes can no longer be " - "accessed. Use the .previous() method on this object to get " - "a copy of the object at its previous state.".format( - self.entity_type, self.entity_id)) - return self.data[name] + return self.safe_data[name] def __bool__(self): return bool(self.data) @@ -283,6 +295,22 @@ class ModelEntity(object): return self.model.state.entity_data( self.entity_type, self.entity_id, self._history_index) + @property + def safe_data(self): + """The data dictionary for this entity. + + If this `ModelEntity` points to the dead state, it will + raise `DeadEntityException`. + + """ + if self.data is None: + raise DeadEntityException( + "Entity {}:{} is dead - its attributes can no longer be " + "accessed. Use the .previous() method on this object to get " + "a copy of the object at its previous state.".format( + self.entity_type, self.entity_id)) + return self.data + def previous(self): """Return a copy of this object as was at its previous state in history. @@ -395,6 +423,59 @@ class Model(object): await self.connection.close() self.connection = None + async def add_local_charm_dir(self, charm_dir, series): + """Upload a local charm to the model. + + This will automatically generate an archive from + the charm dir. + + :param charm_dir: Path to the charm directory + :param series: Charm series + + """ + fh = tempfile.NamedTemporaryFile() + CharmArchiveGenerator(charm_dir).make_archive(fh.name) + with fh: + func = partial( + self.add_local_charm, fh, series, os.stat(fh.name).st_size) + charm_url = await self.loop.run_in_executor(None, func) + + log.debug('Uploaded local charm: %s -> %s', charm_dir, charm_url) + return charm_url + + def add_local_charm(self, charm_file, series, size=None): + """Upload a local charm archive to the model. + + Returns the 'local:...' url that should be used to deploy the charm. + + :param charm_file: Path to charm zip archive + :param series: Charm series + :param size: Size of the archive, in bytes + :return str: 'local:...' url for deploying the charm + :raises: :class:`JujuError` if the upload fails + + Uses an https endpoint at the same host:port as the wss. + Supports large file uploads. + + .. warning:: + + This method will block. Consider using :meth:`add_local_charm_dir` + instead. + + """ + conn, headers, path_prefix = self.connection.https_connection() + path = "%s/charms?series=%s" % (path_prefix, series) + headers['Content-Type'] = 'application/zip' + if size: + headers['Content-Length'] = size + conn.request("POST", path, charm_file, headers) + response = conn.getresponse() + result = response.read().decode() + if not response.status == 200: + raise JujuError(result) + result = json.loads(result) + return result['charm-url'] + def all_units_idle(self): """Return True if all units are idle. @@ -423,13 +504,13 @@ class Model(object): lambda: len(self.machines) == 0 ) - async def block_until(self, *conditions, timeout=None): + async def block_until(self, *conditions, timeout=None, wait_period=0.5): """Return only after all conditions are true. """ async def _block(): while not all(c() for c in conditions): - await asyncio.sleep(0) + await asyncio.sleep(wait_period) await asyncio.wait_for(_block(), timeout) @property @@ -483,7 +564,7 @@ class Model(object): """Register an "on-model-change" callback Once the model is connected, ``callable_`` - will be called each time the model changes. callable_ should + will be called each time the model changes. ``callable_`` should be Awaitable and accept the following positional arguments: delta - An instance of :class:`juju.delta.EntityDelta` @@ -502,14 +583,15 @@ class Model(object): model - The :class:`Model` itself. Events for which ``callable_`` is called can be specified by passing - entity_type, action, and/or id_ filter criteria, e.g.: + entity_type, action, and/or entitiy_id filter criteria, e.g.:: add_observer( - myfunc, entity_type='application', action='add', id_='ubuntu') + myfunc, + entity_type='application', action='add', entity_id='ubuntu') For more complex filtering conditions, pass a predicate function. It will be called with a delta as its only argument. If the predicate - function returns True, the callable_ will be called. + function returns True, the ``callable_`` will be called. """ observer = _Observer( @@ -806,14 +888,14 @@ class Model(object): pass async def deploy( - self, entity_url, service_name=None, bind=None, budget=None, + self, entity_url, application_name=None, bind=None, budget=None, channel=None, config=None, constraints=None, force=False, num_units=1, plan=None, resources=None, series=None, storage=None, to=None): """Deploy a new service or bundle. :param str entity_url: Charm or bundle url - :param str service_name: Name to give the service + :param str application_name: Name to give the service :param dict bind: : pairs :param dict budget: : pairs :param str channel: Charm store channel from which to retrieve @@ -839,7 +921,7 @@ class Model(object): TODO:: - - service_name is required; fill this in automatically if not + - application_name is required; fill this in automatically if not provided by caller - series is required; how do we pick a default? @@ -857,14 +939,23 @@ class Model(object): for k, v in storage.items() } - entity_id = await self.charmstore.entityId(entity_url) + is_local = ( + entity_url.startswith('local:') or + os.path.isdir(entity_url) + ) + entity_id = await self.charmstore.entityId(entity_url) \ + if not is_local else entity_url app_facade = client.ApplicationFacade() client_facade = client.ClientFacade() app_facade.connect(self.connection) client_facade.connect(self.connection) - if 'bundle/' in entity_id: + is_bundle = ((is_local and + (Path(entity_id) / 'bundle.yaml').exists()) or + (not is_local and 'bundle/' in entity_id)) + + if is_bundle: handler = BundleHandler(self) await handler.fetch_plan(entity_id) await handler.execute_plan() @@ -875,7 +966,7 @@ class Model(object): # haven't made it yet we'll need to wait on them to be added await asyncio.gather(*[ asyncio.ensure_future( - self.model._wait_for_new('application', app_name)) + self._wait_for_new('application', app_name)) for app_name in pending_apps ]) return [app for name, app in self.applications.items() @@ -884,13 +975,26 @@ class Model(object): log.debug( 'Deploying %s', entity_id) - await client_facade.AddCharm(channel, entity_id) + if not is_local: + await client_facade.AddCharm(channel, entity_id) + elif not entity_id.startswith('local:'): + # We have a local charm dir that needs to be uploaded + charm_dir = os.path.abspath( + os.path.expanduser(entity_id)) + series = series or get_charm_series(charm_dir) + if not series: + raise JujuError( + "Couldn't determine series for charm at {}. " + "Pass a 'series' kwarg to Model.deploy().".format( + charm_dir)) + entity_id = await self.add_local_charm_dir(charm_dir, series) + app = client.ApplicationDeploy( - application=service_name, + application=application_name, channel=channel, charm_url=entity_id, config=config, - constraints=constraints, + constraints=parse_constraints(constraints), endpoint_bindings=bind, num_units=num_units, placement=placement, @@ -899,8 +1003,11 @@ class Model(object): storage=storage, ) - await app_facade.Deploy([app]) - return await self._wait_for_new('application', service_name) + result = await app_facade.Deploy([app]) + errors = [r.error.message for r in result.results if r.error] + if errors: + raise JujuError('\n'.join(errors)) + return await self._wait_for_new('application', application_name) def destroy(self): """Terminate all machines and resources for this model. @@ -920,7 +1027,7 @@ class Model(object): 's' if len(unit_names) == 1 else '', ' '.join(unit_names)) - return await app_facade.Destroy(self.name) + return await app_facade.DestroyUnits(list(unit_names)) destroy_units = destroy_unit def get_backup(self, archive_id): @@ -1240,6 +1347,8 @@ class Model(object): :param str \*tags: Tags of entities from which to retrieve metrics. No tags retrieves the metrics of all units in the model. + :return: Dictionary of unit_name:metrics + """ log.debug("Retrieving metrics for %s", ', '.join(tags) if tags else "all units") @@ -1266,6 +1375,21 @@ class Model(object): return metrics +def get_charm_series(path): + """Inspects the charm directory at ``path`` and returns a default + series from its metadata.yaml (the first item in the 'series' list). + + Returns None if no series can be determined. + + """ + md = Path(path) / "metadata.yaml" + if not md.exists(): + return None + data = yaml.load(md.open()) + series = data.get('series') + return series[0] if series else None + + class BundleHandler(object): """ Handle bundles by using the API to translate bundle YAML into a plan of @@ -1287,12 +1411,70 @@ class BundleHandler(object): self.ann_facade = client.AnnotationsFacade() self.ann_facade.connect(model.connection) + async def _handle_local_charms(self, bundle): + """Search for references to local charms (i.e. filesystem paths) + in the bundle. Upload the local charms to the model, and replace + the filesystem paths with appropriate 'local:' paths in the bundle. + + Return the modified bundle. + + :param dict bundle: Bundle dictionary + :return: Modified bundle dictionary + + """ + apps, args = [], [] + + default_series = bundle.get('series') + for app_name in self.applications: + app_dict = bundle['services'][app_name] + charm_dir = os.path.abspath(os.path.expanduser(app_dict['charm'])) + if not os.path.isdir(charm_dir): + continue + series = ( + app_dict.get('series') or + default_series or + get_charm_series(charm_dir) + ) + if not series: + raise JujuError( + "Couldn't determine series for charm at {}. " + "Add a 'series' key to the bundle.".format(charm_dir)) + + # Keep track of what we need to update. We keep a list of apps + # that need to be updated, and a corresponding list of args + # needed to update those apps. + apps.append(app_name) + args.append((charm_dir, series)) + + if apps: + # If we have apps to update, spawn all the coroutines concurrently + # and wait for them to finish. + charm_urls = await asyncio.gather(*[ + self.model.add_local_charm_dir(*params) + for params in args + ]) + # Update the 'charm:' entry for each app with the new 'local:' url. + for app_name, charm_url in zip(apps, charm_urls): + bundle['services'][app_name]['charm'] = charm_url + + return bundle + async def fetch_plan(self, entity_id): - bundle_yaml = await self.charmstore.files(entity_id, - filename='bundle.yaml', - read_file=True) + is_local = not entity_id.startswith('cs:') and os.path.isdir(entity_id) + if is_local: + bundle_yaml = (Path(entity_id) / "bundle.yaml").read_text() + else: + bundle_yaml = await self.charmstore.files(entity_id, + filename='bundle.yaml', + read_file=True) self.bundle = yaml.safe_load(bundle_yaml) - self.plan = await self.client_facade.GetBundleChanges(bundle_yaml) + self.bundle = await self._handle_local_charms(self.bundle) + + self.plan = await self.client_facade.GetBundleChanges( + yaml.dump(self.bundle)) + + if self.plan.errors: + raise JujuError('\n'.join(self.plan.errors)) async def execute_plan(self): for step in self.plan.changes: @@ -1318,6 +1500,11 @@ class BundleHandler(object): Series holds the series of the charm to be added if the charm default is not sufficient. """ + # We don't add local charms because they've already been added + # by self._handle_local_charms + if charm.startswith('local:'): + return charm + entity_id = await self.charmstore.entityId(charm) log.debug('Adding %s', entity_id) await self.client_facade.AddCharm(None, entity_id) @@ -1330,19 +1517,27 @@ class BundleHandler(object): Keys include: series: string specifying the machine OS series. + constraints: string holding machine constraints, if any. We'll parse this into the json friendly dict that the juju api expects. + container_type: string holding the type of the container (for - instance ""lxc" or kvm"). It is not specified for top level + instance ""lxd" or kvm"). It is not specified for top level machines. + parent_id: string holding a placeholder pointing to another machine change or to a unit change. This value is only specified in the case this machine is a container, in which case also ContainerType is set. + """ params = params or {} + # Normalize keys + params = {normalize_key(k): params[k] for k in params.keys()} + + # Fix up values, as necessary. if 'parent_id' in params: params['parent_id'] = self.resolve(params['parent_id']) @@ -1350,6 +1545,12 @@ class BundleHandler(object): params.get('constraints')) params['jobs'] = params.get('jobs', ['JobHostUnits']) + if params.get('container_type') == 'lxc': + log.warning('Juju 2.0 does not support lxc containers. ' + 'Converting containers to lxd.') + params['container_type'] = 'lxd' + + # Submit the request. params = client.AddMachineParams(**params) results = await self.client_facade.AddMachines([params]) error = results.machines[0].error @@ -1418,7 +1619,7 @@ class BundleHandler(object): series=series, application=application, config=options, - constraints=constraints, + constraints=parse_constraints(constraints), storage=storage, endpoint_bindings=endpoint_bindings, resources=resources, @@ -1513,3 +1714,79 @@ class CharmStore(object): setattr(self, name, coro) wrapper = coro return wrapper + + +class CharmArchiveGenerator(object): + def __init__(self, path): + self.path = os.path.abspath(os.path.expanduser(path)) + + def make_archive(self, path): + """Create archive of directory and write to ``path``. + + :param path: Path to archive + + Ignored:: + + * build/\* - This is used for packing the charm itself and any + similar tasks. + * \*/.\* - Hidden files are all ignored for now. This will most + likely be changed into a specific ignore list + (.bzr, etc) + + """ + zf = zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) + for dirpath, dirnames, filenames in os.walk(self.path): + relative_path = dirpath[len(self.path) + 1:] + if relative_path and not self._ignore(relative_path): + zf.write(dirpath, relative_path) + for name in filenames: + archive_name = os.path.join(relative_path, name) + if not self._ignore(archive_name): + real_path = os.path.join(dirpath, name) + self._check_type(real_path) + if os.path.islink(real_path): + self._check_link(real_path) + self._write_symlink( + zf, os.readlink(real_path), archive_name) + else: + zf.write(real_path, archive_name) + zf.close() + return path + + def _check_type(self, path): + """Check the path + """ + s = os.stat(path) + if stat.S_ISDIR(s.st_mode) or stat.S_ISREG(s.st_mode): + return path + raise ValueError("Invalid Charm at % %s" % ( + path, "Invalid file type for a charm")) + + def _check_link(self, path): + link_path = os.readlink(path) + if link_path[0] == "/": + raise ValueError( + "Invalid Charm at %s: %s" % ( + path, "Absolute links are invalid")) + path_dir = os.path.dirname(path) + link_path = os.path.join(path_dir, link_path) + if not link_path.startswith(os.path.abspath(self.path)): + raise ValueError( + "Invalid charm at %s %s" % ( + path, "Only internal symlinks are allowed")) + + def _write_symlink(self, zf, link_target, link_path): + """Package symlinks with appropriate zipfile metadata.""" + info = zipfile.ZipInfo() + info.filename = link_path + info.create_system = 3 + # Magic code for symlinks / py2/3 compat + # 27166663808 = (stat.S_IFLNK | 0755) << 16 + info.external_attr = 2716663808 + zf.writestr(info, link_target) + + def _ignore(self, path): + if path == "build" or path.startswith("build/"): + return True + if path.startswith('.'): + return True