jujubundlelib-0.4.1/0000775000175000017500000000000012630005375015743 5ustar frankbanfrankban00000000000000jujubundlelib-0.4.1/jujubundlelib.egg-info/0000775000175000017500000000000012630005375022273 5ustar frankbanfrankban00000000000000jujubundlelib-0.4.1/jujubundlelib.egg-info/SOURCES.txt0000664000175000017500000000206212630005365024156 0ustar frankbanfrankban00000000000000AUTHORS.rst CONTRIBUTING.rst LICENSE MANIFEST.in README.rst getchangeset setup.cfg setup.py docs/Makefile docs/authors.rst docs/contributing.rst docs/index.rst docs/installation.rst docs/packaging.rst docs/readme.rst docs/usage.rst jujubundlelib/__init__.py jujubundlelib/changeset.py jujubundlelib/cli.py jujubundlelib/models.py jujubundlelib/pyutils.py jujubundlelib/references.py jujubundlelib/typeutils.py jujubundlelib/utils.py jujubundlelib/validation.py jujubundlelib.egg-info/PKG-INFO jujubundlelib.egg-info/SOURCES.txt jujubundlelib.egg-info/dependency_links.txt jujubundlelib.egg-info/not-zip-safe jujubundlelib.egg-info/requires.txt jujubundlelib.egg-info/top_level.txt jujubundlelib/tests/__init__.py jujubundlelib/tests/helpers.py jujubundlelib/tests/test_changeset.py jujubundlelib/tests/test_cli.py jujubundlelib/tests/test_integration.py jujubundlelib/tests/test_models.py jujubundlelib/tests/test_pyutils.py jujubundlelib/tests/test_references.py jujubundlelib/tests/test_typeutils.py jujubundlelib/tests/test_utils.py jujubundlelib/tests/test_validation.pyjujubundlelib-0.4.1/jujubundlelib.egg-info/dependency_links.txt0000664000175000017500000000000112630005365026340 0ustar frankbanfrankban00000000000000 jujubundlelib-0.4.1/jujubundlelib.egg-info/top_level.txt0000664000175000017500000000001612630005365025021 0ustar frankbanfrankban00000000000000jujubundlelib jujubundlelib-0.4.1/jujubundlelib.egg-info/not-zip-safe0000664000175000017500000000000112630005140024507 0ustar frankbanfrankban00000000000000 jujubundlelib-0.4.1/jujubundlelib.egg-info/requires.txt0000664000175000017500000000001512630005365024666 0ustar frankbanfrankban00000000000000PyYAML==3.11 jujubundlelib-0.4.1/jujubundlelib.egg-info/PKG-INFO0000664000175000017500000000151412630005365023370 0ustar frankbanfrankban00000000000000Metadata-Version: 1.1 Name: jujubundlelib Version: 0.4.1 Summary: A python library for working with Juju bundles Home-page: https://github.com/juju/juju-bundlelib Author: Juju UI Team Author-email: juju-gui@lists.ubuntu.com License: LGPL3 Description: =============== Juju Bundle Lib =============== A Python library for working with Juju bundles. Keywords: juju bundles Platform: UNKNOWN Classifier: Development Status :: 2 - Pre-Alpha Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) Classifier: Natural Language :: English Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 jujubundlelib-0.4.1/setup.cfg0000664000175000017500000000012212630005375017557 0ustar frankbanfrankban00000000000000[wheel] universal = 1 [egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 jujubundlelib-0.4.1/setup.py0000775000175000017500000000252212620167221017457 0ustar frankbanfrankban00000000000000#!/usr/bin/env python # Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from setuptools import ( find_packages, setup, ) PROJECT_NAME = 'jujubundlelib' project = __import__(PROJECT_NAME) with open('README.rst') as readme_file: readme = readme_file.read() requirements = [ 'PyYAML==3.11', ] test_requirements = [ 'tox', ] setup( name=PROJECT_NAME, version=project.get_version(), description='A python library for working with Juju bundles', long_description=readme, author="Juju UI Team", author_email='juju-gui@lists.ubuntu.com', url='https://github.com/juju/juju-bundlelib', scripts=['getchangeset'], packages=find_packages(), include_package_data=True, install_requires=requirements, license="LGPL3", zip_safe=False, keywords='juju bundles', classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 'Natural Language :: English', "Programming Language :: Python :: 2", 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', ], test_suite='tests', tests_require=test_requirements, ) jujubundlelib-0.4.1/CONTRIBUTING.rst0000664000175000017500000000517512620167221020412 0ustar frankbanfrankban00000000000000============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of Contributions ---------------------- Report Bugs ~~~~~~~~~~~ Report bugs at https://github.com/juju/juju-bundlelib/issues. If you are reporting a bug, please include: * Your operating system name and version. * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix Bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" is open to whoever wants to implement it. Implement Features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues for features. Anything tagged with "feature" is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ Juju Bundle Lib could always use more documentation, whether as part of the official Juju Bundle Lib docs, in docstrings, or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to file an issue at https://github.com/juju/juju-bundlelib/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. Get Started! ------------ Ready to contribute? Here's how to set up `jujubundlelib` for local development. 1. Fork the `juju-bundlelib` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/juju-bundlelib.git 3. Prepare your development environment:: $ make devenv 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: $ make check 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. 3. The pull request should work for Python 2.7 and 3.4, and for PyPy. Tips ---- To run a subset of tests:: $ devenv/bin/nosetests jujubundlelib/tests/... jujubundlelib-0.4.1/MANIFEST.in0000664000175000017500000000026512620167221017502 0ustar frankbanfrankban00000000000000include AUTHORS.rst include CONTRIBUTING.rst include LICENSE include README.rst recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-include docs *.rst Makefile jujubundlelib-0.4.1/jujubundlelib/0000775000175000017500000000000012630005375020601 5ustar frankbanfrankban00000000000000jujubundlelib-0.4.1/jujubundlelib/references.py0000664000175000017500000002570212630005115023272 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import re from jujubundlelib import pyutils # The URL of jujucharms.com, the home of Juju. JUJUCHARMS_URL = 'https://jujucharms.com/' # The following regular expressions are the same used in juju-core: see # http://bazaar.launchpad.net/~go-bot/juju-core/trunk/view/head:/charm/url.go. USER_PATTERN = r'[a-z0-9][a-zA-Z0-9+.-]+' SERIES_PATTERN = r'[a-z]+(?:[a-z-]+[a-z])?' NAME_PATTERN = r'[a-z][a-z0-9]*(?:-[a-z0-9]*[a-z][a-z0-9]*)*' # Define the name of the development channel. DEVELOPMENT_CHANNEL = 'development' # Define the callables used to check if entity reference components are valid. valid_user = re.compile(r'^{}$'.format(USER_PATTERN)).match valid_name = re.compile(r'^{}$'.format(NAME_PATTERN)).match valid_series = re.compile(r'^{}$'.format(SERIES_PATTERN)).match valid_channel = lambda channel: channel == DEVELOPMENT_CHANNEL # Compile the regular expression used to parse new jujucharms entity URLs. _jujucharms_url_expression = re.compile(r""" ^ # Beginning of the line. (?: (?:{jujucharms})? # Optional jujucharms.com URL. | /? # Optional leading slash. )? (?:u/({user_pattern})/)? # Optional user name. (?:({development})/)? # Optional channel. ({name_pattern}) # Bundle name. (?:/({series_pattern}))? # Optional series. (?:/(\d+))? # Optional bundle revision number. /? # Optional trailing slash. $ # End of the line. """.format( development=DEVELOPMENT_CHANNEL, jujucharms=JUJUCHARMS_URL, name_pattern=NAME_PATTERN, series_pattern=SERIES_PATTERN, user_pattern=USER_PATTERN, ), re.VERBOSE) @pyutils.string_class class Reference(object): """Represent a charm or bundle URL reference.""" def __init__(self, schema, user, channel, series, name, revision): """Initialize the reference. Receives the URL fragments.""" self.schema = schema self.user = user self.channel = channel self.series = series self.name = name if revision is not None: revision = int(revision) self.revision = revision # XXX frankban 2015-02-26: remove the following attribute when # switching to the new bundle format, and when we have a better way # to increase bundle deployments count. self.charmworld_id = None @classmethod def from_string(cls, url): """Given an entity URL as a string, create and return a Reference. The given URL may be not fully qualified, meaning it can miss the schema (in which case "cs:" is inferred), the series (defaulting to "") and the revision (set to None if not specified). Raise a ValueError if the provided value is not a valid URL. """ return cls(*_parse_url(url, fully_qualified=False)) @classmethod def from_fully_qualified_url(cls, url): """Given an entity URL as a string, create and return a Reference. Fully qualified URLs represent the regular entity reference representation in Juju, e.g.: "cs:`~who/vivid/django-42" or "local:bundle/wordpress-0". Raise a ValueError if the provided value is not a valid and fully qualified URL, also including the schema and the revision. """ return cls(*_parse_url(url, fully_qualified=True)) @classmethod def from_jujucharms_url(cls, url): """Create and return a Reference from the given jujucharms.com URL. These are the preferred way to refer to a charm or bundle They basically look like the URL paths in jujucharms.com, e.g. "u/who/django", "mediawiki/42" or just "mediawiki". The full HTTP URL can be also provided, for instance "https://jujucharms.com/django". Raise a ValueError if the provided URL is not valid. """ match = _jujucharms_url_expression.match(url) if match is None: msg = 'invalid charm or bundle URL: {}'.format(url) raise ValueError(msg.encode('utf-8')) user, channel, name, series, revision = match.groups() return cls( 'cs', user or '', channel or '', series or '', name, revision) def __str__(self): """The string representation of a reference is its URL string.""" return self.id() def __repr__(self): return ''.format(self) def __eq__(self, other): """Two refs are equal if they have the same parts.""" return ( isinstance(other, self.__class__) and self.schema == other.schema and self.user == other.user and self.channel == other.channel and self.series == other.series and self.name == other.name and self.revision == other.revision ) def path(self): """Return the reference as a string without the schema.""" user = '~{}'.format(self.user) if self.user else '' name_revision = self.name if self.revision is not None: name_revision += '-{}'.format(self.revision) return '/'.join( filter(None, [user, self.channel, self.series, name_revision])) def id(self): """Return the reference URL as a string.""" return '{}:{}'.format(self.schema, self.path()) def similar(self, other): """Report whether the other reference refers to a similar charm. Two references are considered similar if they share the same schema, user and name. Raise a TypeError if the given reference is not a Reference instance. """ if not isinstance(other, self.__class__): msg = 'cannot compare unsupported type {}'.format( other.__class__.__name__) raise TypeError(msg.encode('utf-8')) return ( (self.schema, self.user, self.name) == (other.schema, other.user, other.name)) def copy(self, **kwargs): """Copy this reference. If keyword arguments are passed, the copied reference will have the corresponding attributes. For instance: ref = reference.copy() ref = reference.copy(revision=42) ref = reference.copy(channel='', user='') """ reference = self.__class__( self.schema, self.user, self.channel, self.series, self.name, self.revision) for key, value in kwargs.items(): setattr(reference, key, value) return reference def jujucharms_id(self): """Return the identifier of this reference in jujucharms.com.""" user_part = 'u/{}/'.format(self.user) if self.user else '' channel_part = '{}/'.format(self.channel) if self.channel else '' series_part = '/{}'.format(self.series) if self.series else '' revision_part = '' if self.revision is not None: revision_part = '/{}'.format(self.revision) return '{}{}{}{}{}'.format( user_part, channel_part, self.name, series_part, revision_part) def jujucharms_url(self): """Return the URL where this entity lives in jujucharms.com.""" return JUJUCHARMS_URL + self.jujucharms_id() def is_bundle(self): """Report whether this reference refers to a bundle entity.""" return self.series == 'bundle' def is_local(self): """Return True if this refers to a local entity, False otherwise.""" return self.schema == 'local' def is_fully_qualified(self): """Report whether this reference is fully qualified. A fully qualified reference includes its schema, series and revision. """ return self.schema and self.series and (self.revision is not None) def is_under_development(self): """Report whether this reference points to a development entity.""" return self.channel == DEVELOPMENT_CHANNEL def _parse_url(url, fully_qualified=False): """Parse the given charm or bundle URL, provided as a string. Return a tuple containing the entity reference fragments: schema, user, channel, series, name and revision. Each fragment is a string except revision (int). Raise a ValueError with a descriptive message if the given URL is not valid. If fully_qualified is True, the URL must include the schema, series and revision, otherwise a ValueError is raised. """ # Retrieve the schema. try: schema, remaining = url.split(':', 1) except ValueError: if fully_qualified: msg = 'URL has no schema: {}'.format(url) raise ValueError(msg.encode('utf-8')) schema = 'cs' remaining = url if schema not in ('cs', 'local'): msg = 'URL has invalid schema: {}'.format(schema) raise ValueError(msg.encode('utf-8')) # Retrieve and validate the optional user. parts = remaining.split('/') part = parts.pop(0) user = '' if part.startswith('~'): user = part[1:] if not valid_user(user): msg = 'URL has invalid user name: {}'.format(user) raise ValueError(msg.encode('utf-8')) if schema == 'local': msg = 'local entity URL with user name: {}'.format(url) raise ValueError(msg.encode('utf-8')) if not parts: msg = 'URL has invalid form: {}'.format(url) raise ValueError(msg.encode('utf-8')) part = parts.pop(0) # Retrieve and validate the optional channel. channel = '' if valid_channel(part) and parts: channel = part if schema == 'local': msg = 'local entity URL with channel: {}'.format(url) raise ValueError(msg.encode('utf-8')) part = parts.pop(0) # Retrieve and validate the series. series = '' if parts: series = part if not valid_series(series): msg = 'URL has invalid series: {}'.format(series) raise ValueError(msg.encode('utf-8')) part = parts.pop(0) elif fully_qualified: msg = 'URL has invalid form: {}'.format(url) raise ValueError(msg.encode('utf-8')) # Retrieve and validate name and revision. if parts: msg = 'URL has invalid form: {}'.format(url) raise ValueError(msg.encode('utf-8')) try: name, revision = part.rsplit('-', 1) except ValueError: if fully_qualified: msg = 'URL has no revision: {}'.format(url) raise ValueError(msg.encode('utf-8')) name, revision = part, None if revision is not None: try: revision = int(revision) except ValueError: if fully_qualified: msg = 'URL has invalid revision: {}'.format(revision) raise ValueError(msg.encode('utf-8')) name, revision = name + '-' + revision, None if not valid_name(name): msg = 'URL has invalid name: {}'.format(name) raise ValueError(msg.encode('utf-8')) return schema, user, channel, series, name, revision jujubundlelib-0.4.1/jujubundlelib/typeutils.py0000664000175000017500000000117012620167221023212 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import collections from jujubundlelib import pyutils def isdict(value): """Report whether the given value is a dict-like object.""" return isinstance(value, collections.Mapping) def islist(value): """Report whether the given value is a sequence.""" return isinstance(value, (list, tuple)) def isstring(value): """Report whether the given value is a byte or unicode string.""" classes = (str, bytes) if pyutils.PY3 else basestring return isinstance(value, classes) jujubundlelib-0.4.1/jujubundlelib/validation.py0000664000175000017500000003633012627011034023305 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import models import pyutils import references from typeutils import ( isdict, islist, isstring, ) # Define accepted constraint types. _CONSTRAINTS = ( 'arch', 'container', 'cpu', 'cpu-cores', 'cpu-power', 'instance-type', 'mem', 'networks', 'root-disk', 'spaces', 'tags', ) def validate(bundle): """Validate a bundle object and all of its components. The bundle must be passed as a YAML decoded object. Return a list of bundle errors, or an empty list if the bundle is valid. """ errors = [] add_error = errors.append # Check that the bundle sections are well formed. series, services, machines, relations = _validate_sections( bundle, add_error) # If there are errors already, there is no point in proceeding with the # validation process. if errors: return errors # Validate each individual section. _validate_series(series, 'bundle', add_error) _validate_services(services, machines, add_error) _validate_machines(machines, add_error) _validate_relations(relations, services, add_error) # Return all the collected errors. return errors def _validate_sections(bundle, add_error): """Check that the base bundle sections are valid. The bundle argument is a YAML decoded bundle content. A bundle is composed of series, services, machines and relations. Only the services section is mandatory. Use the given add_error callable to register validation error. Return the four sections """ # Check that the bundle itself is well formed. if not isdict(bundle): add_error('bundle does not appear to be a bundle') return None, None, None, None # Validate the services section. services = bundle.get('services', {}) if not services: add_error('bundle does not define any services') elif not isdict(services): add_error('services spec does not appear to be well-formed') # Validate the machines section. machines = bundle.get('machines') if machines is not None: if isdict(machines): try: machines = dict((int(k), v) for k, v in machines.items()) except (TypeError, ValueError): add_error('machines spec identifiers must be digits') else: add_error('machines spec does not appear to be well-formed') # Validate the relations section. relations = bundle.get('relations') if (relations is not None) and (not islist(relations)): add_error('relations spec does not appear to be well-formed') return bundle.get('series'), services, machines, relations def _validate_series(series, label, add_error): """Check that the given series is valid. Use the given label (e.g. "machine X" or just "bundle") to describe possible errors. Use the given add_error callable to register validation error. """ if series is None: return if not isstring(series): add_error('{} series must be a string, found {}'.format(label, series)) return if series == 'bundle': add_error('{} series must specify a charm series'.format(label)) return if not references.valid_series(series): add_error('{} has invalid series {}'.format(label, series)) def _validate_services(services, machines, add_error): """Validate each service within the bundle. Receive the services and machines sections of the bundle. Use the given add_error callable to register validation error. """ machine_ids = set() for service_name, service in services.items(): if not isstring(service_name): add_error('service name {} must be a string'.format(service_name)) if service.get('expose') not in (True, False, None): add_error( 'invalid expose value for service {}'.format(service_name)) # Validate and retrieve the service charm URL and number of units. charm = _validate_charm(service.get('charm'), service_name, add_error) num_units = _validate_num_units( service.get('num_units'), service_name, add_error) # Validate service constraints and storage constraints. label = 'service {}'.format(service_name) _validate_constraints(service.get('constraints'), label, add_error) _validate_storage(service.get('storage'), service_name, add_error) # Validate service options and annotations. _validate_options(service.get('options'), service_name, add_error) _validate_annotations(service.get('annotations'), label, add_error) # Retrieve and validate the service units placement. placements = service.get('to', []) if not islist(placements): placements = [placements] if (num_units is not None) and (len(placements) > num_units): add_error( 'too many units placed for service {}'.format(service_name)) for placement in placements: machine_id = _validate_placement( placement, services, machines, charm, add_error) machine_ids.add(machine_id) if machines is not None: # Notify unused machines. unused = set(machines).difference(machine_ids) for machine_id in unused: add_error( 'machine {} not referred to by a placement directive' ''.format(machine_id)) def _validate_charm(url, service_name, add_error): """Validate the given charm URL. Use the given service name to describe possible errors. Use the given add_error callable to register validation error. If the URL is valid, return the corresponding charm reference object. Return None otherwise. """ if url is None: add_error('no charm specified for service {}'.format(service_name)) return None if not isstring(url): add_error( 'invalid charm specified for service {}: {}' ''.format(service_name, url)) return None if not url.strip(): add_error('empty charm specified for service {}'.format(service_name)) return None try: charm = references.Reference.from_string(url) except ValueError as e: msg = pyutils.exception_string(e) add_error( 'invalid charm specified for service {}: {}' ''.format(service_name, msg)) return None if charm.is_local(): add_error( 'local charms not allowed for service {}: {}' ''.format(service_name, charm)) return None if charm.is_bundle(): add_error( 'bundle cannot be used as charm for service {}: {}' ''.format(service_name, charm)) return None return charm def _validate_num_units(num_units, service_name, add_error): """Check that the given num_units is valid. Use the given service name to describe possible errors. Use the given add_error callable to register validation error. If no errors are encountered, return the number of units as an integer. Return None otherwise. """ if num_units is None: # This should be a subordinate charm. return 0 try: num_units = int(num_units) except (TypeError, ValueError): add_error( 'num_units for service {} must be a digit'.format(service_name)) return if num_units < 0: add_error( 'num_units {} for service {} must be a positive digit' ''.format(num_units, service_name)) return return num_units def _validate_constraints(constraints, label, add_error): """Validate the given service or machine constraints. Use the given label (e.g. "machine X" or "service Y") to describe possible errors. Use the given add_error callable to register validation error. """ if constraints is None: return msg = '{} has invalid constraints {}'.format(label, constraints) if not isstring(constraints): add_error(msg) return sep = ',' if ',' in constraints else None for constraint in constraints.split(sep): try: key, value = constraint.split('=') except (TypeError, ValueError): add_error(msg) return if key not in _CONSTRAINTS: add_error(msg) def _validate_storage(storage, service_name, add_error): """Lazily validate the storage constraints, ensuring that they are a dict. Use the given add_error callable to register validation error. """ if storage is None: return if not isdict(storage): msg = 'service {} has invalid storage constraints {}'.format( service_name, storage) add_error(msg) def _validate_options(options, service_name, add_error): """Lazily validate the options, ensuring that they are a dict. Use the given add_error callable to register validation error. """ if options is None: return if not isdict(options): add_error('service {} has malformed options'.format(service_name)) def _validate_annotations(annotations, label, add_error): """Check that the given service or machine annotations are valid. Use the given label (e.g. "machine X" or "service Y") to describe possible errors. Use the given add_error callable to register validation error. """ if annotations is None: return if not isdict(annotations): add_error('{} has invalid annotations {}'.format(label, annotations)) return # Check that all the annotations keys are strings. if not all(map(isstring, annotations)): add_error( '{} has invalid annotations: keys must be strings'.format(label)) def _validate_placement(placement, services, machines, charm, add_error): """Validate a placement directive against other services. Receive the placement (possibly as a string), the services and machines bundle sections, the corresponding charm (or None if invalid) and the add_error callable used to register validation errors. If applicable, also validate the placement of other machines within the bundle. Note that some of the logic within this differs between legacy and version 4 bundles. Return the placement machine id if applicable, None otherwise. """ if not isstring(placement): add_error( 'invalid placement {}: placement must be a string' ''.format(placement)) return is_legacy_bundle = machines is None try: if is_legacy_bundle: # This is a v3 legacy bundle. unit_placement = models.parse_v3_unit_placement(placement) # This is a v4 new style bundle. else: unit_placement = models.parse_v4_unit_placement(placement) except ValueError as e: add_error(pyutils.exception_string(e)) return if unit_placement.service: service = services.get(unit_placement.service) if service is None: add_error( 'placement {} refers to non-existent service {}' ''.format(placement, unit_placement.service)) return if unit_placement.unit is not None: try: num_units = int(service['num_units']) except (TypeError, ValueError): # This will be notified when validating the service itself. pass else: if int(unit_placement.unit) + 1 > num_units: add_error( 'placement {} specifies a unit greater than the units ' 'in service {}' ''.format(placement, unit_placement.service)) elif ( unit_placement.machine and not is_legacy_bundle and (unit_placement.machine != 'new') ): machine_id = int(unit_placement.machine) # A machine can be included in machines but its value can be None. # This is so that we are compatible with go-style YAML unmarshaling. if machine_id not in machines: add_error( 'placement {} refers to a non-existent machine {}' ''.format(placement, unit_placement.machine)) return machine = machines[machine_id] if not isdict(machine): # Ignore this error here, as it is emitted while validating the # machines section of the bundle. machine = {} # If the unit is "hulk smashed", then we need to check that the charm # and the machine series match. if not unit_placement.container_type: series = machine.get('series') if charm.series and series and charm.series != series: # If the machine series is invalid, ignore this check, as an # error for the machine will be added elsewhere. errors = [] _validate_series(series, '', errors.append) if not errors: add_error( 'charm {} cannot be deployed to machine with ' 'different series {}'.format(charm, series)) return machine_id def _validate_machines(machines, add_error): """Validate the given machines section. Validation includes machines constraints, series and annotations. Use the given add_error callable to register validation error. """ if not machines: return for machine_id, machine in machines.items(): if machine_id < 0: add_error( 'machine {} has an invalid id, must be positive digit' ''.format(machine_id)) if machine is None: continue elif not isdict(machine): add_error( 'machine {} does not appear to be well-formed' ''.format(machine_id)) continue label = 'machine {}'.format(machine_id) _validate_constraints(machine.get('constraints'), label, add_error) _validate_series(machine.get('series'), label, add_error) _validate_annotations(machine.get('annotations'), label, add_error) def _validate_relations(relations, services, add_error): """Validate relations, ensuring that the endpoints exist. Receive the relations and services bundle sections. Use the given add_error callable to register validation error. """ if not relations: return for relation in relations: if not islist(relation): add_error('relation {} is malformed'.format(relation)) continue relation_str = ' -> '.join('{}'.format(i) for i in relation) for endpoint in relation: if not isstring(endpoint): add_error( 'relation {} has malformed endpoint {}' ''.format(relation_str, endpoint)) continue try: service, _ = endpoint.split(':') except ValueError: service = endpoint if service not in services: add_error( 'relation {} endpoint {} refers to a non-existent service ' '{}'.format(relation_str, endpoint, service)) jujubundlelib-0.4.1/jujubundlelib/models.py0000664000175000017500000000711512620167221022440 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals from collections import namedtuple VALID_CONTAINERS = ( 'lxc', 'kvm', ) # Define a tuple holding a specific unit placement. UnitPlacement = namedtuple( 'UnitPlacement', [ 'container_type', 'machine', 'service', 'unit', ] ) # Define a relation object. Relation = namedtuple('Relation', ['name', 'interface']) def parse_v3_unit_placement(placement_str): """Return a UnitPlacement for bundles version 3, given a placement string. See https://github.com/juju/charmstore/blob/v4/docs/bundles.md Raise a ValueError if the placement is not valid. """ placement = placement_str container = machine = service = unit = '' if ':' in placement: try: container, placement = placement_str.split(':') except ValueError: msg = 'placement {} is malformed, too many parts'.format( placement_str) raise ValueError(msg.encode('utf-8')) if '=' in placement: try: placement, unit = placement.split('=') except ValueError: msg = 'placement {} is malformed, too many parts'.format( placement_str) raise ValueError(msg.encode('utf-8')) if placement.isdigit(): machine = placement else: service = placement if (container and container not in VALID_CONTAINERS): msg = 'invalid container {} for placement {}'.format( container, placement_str) raise ValueError(msg.encode('utf-8')) unit = _parse_unit(unit, placement_str) if machine and machine != '0': raise ValueError(b'legacy bundles may not place units on machines ' b'other than 0') return UnitPlacement(container, machine, service, unit) def parse_v4_unit_placement(placement_str): """Return a UnitPlacement for bundles version 4, given a placement string. See https://github.com/juju/charmstore/blob/v4/docs/bundles.md Raise a ValueError if the placement is not valid. """ placement = placement_str container = machine = service = unit = '' if ':' in placement: try: container, placement = placement_str.split(':') except ValueError: msg = 'placement {} is malformed, too many parts'.format( placement_str) raise ValueError(msg.encode('utf-8')) if '/' in placement: try: placement, unit = placement.split('/') except ValueError: msg = 'placement {} is malformed, too many parts'.format( placement_str) raise ValueError(msg.encode('utf-8')) if placement.isdigit() or placement == 'new': machine = placement else: service = placement if (container and container not in VALID_CONTAINERS): msg = 'invalid container {} for placement {}'.format( container, placement_str) raise ValueError(msg.encode('utf-8')) unit = _parse_unit(unit, placement_str) return UnitPlacement(container, machine, service, unit) def _parse_unit(unit, placement_str): """Parse a unit as part of the unit placement. Return the unit as an integer or None. Raise a ValueError if the unit is specified but it is not a digit. """ if not unit: return None try: return int(unit) except (TypeError, ValueError): msg = 'unit in placement {} must be digit'.format(placement_str) raise ValueError(msg.encode('utf-8')) jujubundlelib-0.4.1/jujubundlelib/tests/0000775000175000017500000000000012630005375021743 5ustar frankbanfrankban00000000000000jujubundlelib-0.4.1/jujubundlelib/tests/test_pyutils.py0000664000175000017500000000344112620167221025065 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import unittest from jujubundlelib import pyutils class TestStringClass(unittest.TestCase): @unittest.skipIf(pyutils.PY3, 'only run under Python 2') def test_python2(self): cls = type(b'Example', (object,), {'__str__': lambda self: 'example'}) instance = pyutils.string_class(cls)() byte_string = str(instance) self.assertIsInstance(byte_string, bytes) self.assertEqual(b'example', byte_string) unicode_string = unicode(instance) self.assertNotIsInstance(unicode_string, bytes) self.assertEqual('example', unicode_string) @unittest.skipUnless(pyutils.PY3, 'only run under Python 3') def test_python3(self): cls = type('Example', (), {'__str__': lambda self: 'example'}) instance = pyutils.string_class(cls)() unicode_string = str(instance) self.assertIsInstance(unicode_string, str) self.assertEqual('example', unicode_string) with self.assertRaises(AttributeError): self.instance.__unicode__() @unittest.skipIf(pyutils.PY3, 'only run under Python 2') def test_error(self): non_string_class = type(b'ExampleNonString', (object,), {}) with self.assertRaises(TypeError) as ctx: pyutils.string_class(non_string_class) self.assertEqual( 'the given class has no __str__ method', str(ctx.exception)) class TestExceptionString(unittest.TestCase): def test_exception_string(self): msg = 'bad-wolf' e = ValueError(msg.encode('utf-8')) message = pyutils.exception_string(e) self.assertNotIsInstance(message, bytes) self.assertEqual('bad-wolf', message) jujubundlelib-0.4.1/jujubundlelib/tests/test_models.py0000664000175000017500000001104612620167221024637 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import unittest from jujubundlelib import models from jujubundlelib.tests import helpers class TestParseV3UnitPlacement( helpers.ValueErrorTestsMixin, unittest.TestCase): def test_success(self): self.assertEqual( models.UnitPlacement('', '', '', None), models.parse_v3_unit_placement(''), ) self.assertEqual( models.UnitPlacement('', '0', '', None), models.parse_v3_unit_placement('0'), ) self.assertEqual( models.UnitPlacement('', '', 'mysql', None), models.parse_v3_unit_placement('mysql'), ) self.assertEqual( models.UnitPlacement('lxc', '0', '', None), models.parse_v3_unit_placement('lxc:0'), ) self.assertEqual( models.UnitPlacement('', '', 'mysql', 1), models.parse_v3_unit_placement('mysql=1'), ) self.assertEqual( models.UnitPlacement('lxc', '', 'mysql', 1), models.parse_v3_unit_placement('lxc:mysql=1'), ) def test_failure(self): tests = ( { 'about': 'extra container', 'placement': 'lxc:lxc:0', 'error': b'placement lxc:lxc:0 is malformed, too many parts', }, { 'about': 'extra unit', 'placement': 'mysql=0=0', 'error': b'placement mysql=0=0 is malformed, too many parts', }, { 'about': 'bad container', 'placement': 'asdf:0', 'error': b'invalid container asdf for placement asdf:0', }, { 'about': 'bad unit', 'placement': 'foo=a', 'error': b'unit in placement foo=a must be digit', }, { 'about': 'place to machine other than bootstrap node', 'placement': '1', 'error': b'legacy bundles may not place units on machines ' b'other than 0', }, ) for test in tests: with self.assert_value_error(test['error'], test['about']): models.parse_v3_unit_placement(test['placement']) class TestParseV4UnitPlacement( helpers.ValueErrorTestsMixin, unittest.TestCase): def test_success(self): self.assertEqual( models.UnitPlacement('', '', '', None), models.parse_v4_unit_placement(''), ) self.assertEqual( models.UnitPlacement('', '0', '', None), models.parse_v4_unit_placement('0'), ) self.assertEqual( models.UnitPlacement('', '', 'mysql', None), models.parse_v4_unit_placement('mysql'), ) self.assertEqual( models.UnitPlacement('lxc', '0', '', None), models.parse_v4_unit_placement('lxc:0'), ) self.assertEqual( models.UnitPlacement('', '', 'mysql', 1), models.parse_v4_unit_placement('mysql/1'), ) self.assertEqual( models.UnitPlacement('lxc', '', 'mysql', 1), models.parse_v4_unit_placement('lxc:mysql/1'), ) self.assertEqual( models.UnitPlacement('', 'new', '', None), models.parse_v4_unit_placement('new'), ) self.assertEqual( models.UnitPlacement('lxc', 'new', '', None), models.parse_v4_unit_placement('lxc:new'), ) def test_failure(self): tests = ( { 'about': 'extra container', 'placement': 'lxc:lxc:0', 'error': b'placement lxc:lxc:0 is malformed, too many parts', }, { 'about': 'extra unit', 'placement': 'mysql/0/0', 'error': b'placement mysql/0/0 is malformed, too many parts', }, { 'about': 'bad container', 'placement': 'asdf:0', 'error': b'invalid container asdf for placement asdf:0', }, { 'about': 'bad unit', 'placement': 'foo/a', 'error': b'unit in placement foo/a must be digit', }, ) for test in tests: with self.assert_value_error(test['error'], test['about']): models.parse_v4_unit_placement(test['placement']) jujubundlelib-0.4.1/jujubundlelib/tests/test_integration.py0000664000175000017500000000536312620167221025704 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import ( print_function, unicode_literals, ) import json import os import pprint import traceback import unittest try: from urllib.request import ( HTTPError, urlopen, ) except ImportError: from urllib2 import ( HTTPError, urlopen, ) import yaml from jujubundlelib import ( changeset, references, validation, ) # Define the URL to the charm store API. CHARMSTORE_URL = 'https://api.jujucharms.com/charmstore/v4/' # Define the name of the environment variable used to run the functional tests. FTEST_ENV_VAR = 'JUJU_BUNDLELIB_FTESTS' def get_references(): """Retrieve the bundle references from the charm store.""" response = urlopen(CHARMSTORE_URL + 'search?series=bundle&limit=10000') content = response.read().decode('utf-8') data = json.loads(content) for result in data['Results']: yield references.Reference.from_fully_qualified_url(result['Id']) def get_bundle(reference): """Retrieve the bundle content for the given reference from the store.""" response = urlopen( CHARMSTORE_URL + reference.path() + '/archive/bundle.yaml') return yaml.load(response) skip_if_ftests_disabled = unittest.skipUnless( os.getenv(FTEST_ENV_VAR) == '1', 'to run functional tests, set {} to "1"'.format(FTEST_ENV_VAR)) @skip_if_ftests_disabled class TestFunctional(unittest.TestCase): def setUp(self): # Collect bundles from the charm store. self.references = get_references() def test_bundle(self): # All the charm store bundles pass validation. # It is possible to get the change set corresponding to each bundle. # This test ensures there are no false positives when validating a # bundle: charm store bundles are assumed to be valid. # Note that this test requires network connection. for ref in self.references: try: bundle = get_bundle(ref) except HTTPError as err: print('skipping {}: {}'.format(ref, err)) continue # Check bundle validation. errors = validation.validate(bundle) self.assertEqual( [], errors, 'ref: {}\n{}\nerrors: {}'.format( ref, pprint.pformat(bundle), errors)) # Check change set generation. try: changes = list(changeset.parse(bundle)) except: msg = 'changeset parsing error\nref: {}\n{}\n{}'.format( ref, pprint.pformat(bundle), traceback.format_exc()) self.fail(msg) self.assertTrue(changes) jujubundlelib-0.4.1/jujubundlelib/tests/test_typeutils.py0000664000175000017500000000246212620167221025420 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import collections import unittest from jujubundlelib import typeutils class TestIsdict(unittest.TestCase): def test_dict(self): for value in ({}, collections.OrderedDict()): self.assertTrue(typeutils.isdict(value), str(value)) def test_non_dict(self): for value in ('', [1, 2], 42, None, object()): self.assertFalse(typeutils.isdict(value), str(value)) class TestIslist(unittest.TestCase): def test_list(self): tests = ( [1, 2], (), ('foo', 'bar'), collections.namedtuple('Test', 'test')('test'), ) for value in tests: self.assertTrue(typeutils.islist(value), str(value)) def test_non_list(self): for value in ('', {}, 42, None, object()): self.assertFalse(typeutils.islist(value), str(value)) class TestIsstring(unittest.TestCase): def test_string(self): for value in ('', 'foo', b'bar'): self.assertTrue(typeutils.isstring(value), str(value)) def test_non_string(self): for value in ([1, 2], {}, 42, None, object()): self.assertFalse(typeutils.isstring(value), str(value)) jujubundlelib-0.4.1/jujubundlelib/tests/test_utils.py0000664000175000017500000000067212620167221024517 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import unittest from jujubundlelib import utils class TestUtils(unittest.TestCase): def test_is_legacy_bundle(self): self.assertTrue(utils.is_legacy_bundle({'services': {}})) self.assertFalse(utils.is_legacy_bundle({ 'services': {}, 'machines': {}, })) jujubundlelib-0.4.1/jujubundlelib/tests/test_references.py0000664000175000017500000010403512630005115025470 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import unittest from jujubundlelib import ( pyutils, references, ) from jujubundlelib.tests import helpers def make_reference( schema='cs', user='myuser', channel='', series='precise', name='juju-gui', revision=42): """Create and return a Reference instance.""" return references.Reference(schema, user, channel, series, name, revision) class TestReference(unittest.TestCase): representation_tests = ( # Fully qualified. (make_reference(), 'cs:~myuser/precise/juju-gui-42'), # Fully qualified local. (make_reference(schema='local', user=''), 'local:precise/juju-gui-42'), # Promulgated charm. (make_reference(user=''), 'cs:precise/juju-gui-42'), # Custom name, series and revision. (make_reference(name='django', series='vivid', revision=0), 'cs:~myuser/vivid/django-0'), # No series. (make_reference(series=''), 'cs:~myuser/juju-gui-42'), # Promulgated charm without series. (make_reference(user='', series=''), 'cs:juju-gui-42'), # No revision. (make_reference(user='dalek', revision=None, series='bundle'), 'cs:~dalek/bundle/juju-gui'), # Promulgated charm without revision. (make_reference(user='', revision=None), 'cs:precise/juju-gui'), # No series and revision. (make_reference(series='', revision=None), 'cs:~myuser/juju-gui'), # Promulgated charm without series and revision. (make_reference(user='', series='', revision=None), 'cs:juju-gui'), # Fully qualified under development. (make_reference(channel=references.DEVELOPMENT_CHANNEL), 'cs:~myuser/development/precise/juju-gui-42'), # Promulgated charm under development. (make_reference(user='', channel=references.DEVELOPMENT_CHANNEL), 'cs:development/precise/juju-gui-42'), # Custom name, series and revision under development. (make_reference( channel=references.DEVELOPMENT_CHANNEL, name='django', series='vivid', revision=0), 'cs:~myuser/development/vivid/django-0'), # No series under development. (make_reference(channel=references.DEVELOPMENT_CHANNEL, series=''), 'cs:~myuser/development/juju-gui-42'), # Promulgated charm without series under development. (make_reference( user='', channel=references.DEVELOPMENT_CHANNEL, series=''), 'cs:development/juju-gui-42'), # Promulgated charm without revision under development. (make_reference( user='', channel=references.DEVELOPMENT_CHANNEL, revision=None), 'cs:development/precise/juju-gui'), # No series and revision under development. (make_reference( channel=references.DEVELOPMENT_CHANNEL, series='', revision=None), 'cs:~myuser/development/juju-gui'), ) jujucharms_tests = ( (make_reference(), 'u/myuser/juju-gui/precise/42'), (make_reference(schema='local'), 'u/myuser/juju-gui/precise/42'), (make_reference(user=''), 'juju-gui/precise/42'), (make_reference(user='dalek', revision=None, series=''), 'u/dalek/juju-gui'), (make_reference(name='django', series='vivid', revision=0), 'u/myuser/django/vivid/0'), (make_reference(name='django', series='', revision=0), 'u/myuser/django/0'), (make_reference(user='', revision=None), 'juju-gui/precise'), (make_reference(user='', series='', revision=None), 'juju-gui'), (make_reference(channel=references.DEVELOPMENT_CHANNEL), 'u/myuser/development/juju-gui/precise/42'), (make_reference(user='', channel=references.DEVELOPMENT_CHANNEL), 'development/juju-gui/precise/42'), (make_reference(user='dalek', channel=references.DEVELOPMENT_CHANNEL, revision=None, series=''), 'u/dalek/development/juju-gui'), (make_reference(user='dalek', channel=references.DEVELOPMENT_CHANNEL, revision=None, series='bundle'), 'u/dalek/development/juju-gui/bundle'), (make_reference(channel=references.DEVELOPMENT_CHANNEL, name='django', series='vivid', revision=0), 'u/myuser/development/django/vivid/0'), (make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, revision=None), 'development/juju-gui/precise'), (make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='', revision=None), 'development/juju-gui'), (make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='bundle', revision=None), 'development/juju-gui/bundle'), (make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='bundle', revision=0), 'development/juju-gui/bundle/0'), ) def test_attributes(self): # All reference attributes are correctly stored. ref = make_reference() self.assertEqual('cs', ref.schema) self.assertEqual('myuser', ref.user) self.assertEqual('', ref.channel) self.assertEqual('precise', ref.series) self.assertEqual('juju-gui', ref.name) self.assertEqual(42, ref.revision) def test_revision_as_string(self): # The reference revision is converted to an int. ref = make_reference(revision='47') self.assertEqual(47, ref.revision) def test_string(self): # The string representation of a reference is its URL. for ref, expected_value in self.representation_tests: self.assertEqual(expected_value, str(ref)) def test_repr(self): # A reference is correctly represented. for ref, expected_value in self.representation_tests: expected_value = ''.format(expected_value) self.assertEqual(expected_value, repr(ref)) def test_path(self): # The reference path is properly returned as a URL string without the # schema. for ref, expected_value in self.representation_tests: expected_value = expected_value.split(':', 1)[1] self.assertEqual(expected_value, ref.path()) def test_id(self): # The reference id is correctly returned. for ref, expected_value in self.representation_tests: self.assertEqual(expected_value, ref.id()) def test_copy(self): # The reference can be correctly copied. ref = make_reference() copied_ref = ref.copy() self.assertIsNot(ref, copied_ref) self.assertEqual(ref, copied_ref) def test_copy_with_attributes(self): # The reference can be copied overriding specific attributes. ref = make_reference() copy1 = ref.copy(channel=references.DEVELOPMENT_CHANNEL) copy2 = ref.copy(user='', series='wily', revision=0) self.assertNotEqual(ref, copy1) self.assertNotEqual(ref, copy2) self.assertEqual('cs:~myuser/precise/juju-gui-42', str(ref)) self.assertEqual( 'cs:~myuser/development/precise/juju-gui-42', str(copy1)) self.assertEqual('cs:wily/juju-gui-0', str(copy2)) def test_jujucharms_id(self): # It is possible to return the reference identifier in jujucharms.com. for ref, expected_value in self.jujucharms_tests: self.assertEqual(expected_value, ref.jujucharms_id()) def test_jujucharms_url(self): # The corresponding charm or bundle page in jujucharms.com is correctly # returned. for ref, expected_value in self.jujucharms_tests: expected_url = references.JUJUCHARMS_URL + expected_value self.assertEqual(expected_url, ref.jujucharms_url()) def test_charm_entity(self): # The is_bundle method returns False for charm references. ref = make_reference(series='vivid') self.assertFalse(ref.is_bundle()) def test_bundle_entity(self): # The is_bundle method returns True for bundle references. ref = make_reference(series='bundle') self.assertTrue(ref.is_bundle()) def test_charm_store_entity(self): # The is_local method returns False for charm store references. ref = make_reference(schema='cs') self.assertFalse(ref.is_local()) def test_local_entity(self): # The is_local method returns True for local references. ref = make_reference(schema='local') self.assertTrue(ref.is_local()) def test_equality(self): # Two references are equal if they have the same URL. self.assertEqual(make_reference(), make_reference()) self.assertEqual(make_reference(user=''), make_reference(user='')) self.assertEqual( make_reference(channel=references.DEVELOPMENT_CHANNEL), make_reference(channel=references.DEVELOPMENT_CHANNEL)) self.assertEqual( make_reference(revision=None), make_reference(revision=None)) def test_equality_different_references(self): # Two references with different attributes are not equal. tests = ( (make_reference(schema='cs'), make_reference(schema='local')), (make_reference(user=''), make_reference(user='who')), (make_reference(), make_reference(channel=references.DEVELOPMENT_CHANNEL)), (make_reference(series='trusty'), make_reference(series='vivid')), (make_reference(name='django'), make_reference(name='rails')), (make_reference(revision=0), make_reference(revision=1)), (make_reference(revision=None), make_reference(revision=42)), ) for ref1, ref2 in tests: self.assertNotEqual(ref1, ref2) def test_equality_different_types(self): # A reference never equals a non-reference object. self.assertNotEqual(make_reference(), 42) self.assertNotEqual(make_reference(), True) self.assertNotEqual(make_reference(), 'oranges') def test_charmworld_id(self): # By default, the reference id in charmworld is set to None. # XXX frankban 2015-02-26: remove this test once we get rid of the # charmworld id concept. ref = make_reference() self.assertIsNone(ref.charmworld_id) def test_is_fully_qualified(self): # True is returned if the reference is fully qualified. self.assertTrue(make_reference().is_fully_qualified()) self.assertTrue(make_reference(schema='local').is_fully_qualified()) self.assertTrue(make_reference(user='').is_fully_qualified()) self.assertTrue(make_reference( channel=references.DEVELOPMENT_CHANNEL).is_fully_qualified()) self.assertTrue(make_reference(revision=0).is_fully_qualified()) def test_is_not_fully_qualified(self): # False is returned if the reference is not fully qualified. self.assertFalse(make_reference(series='').is_fully_qualified()) self.assertFalse(make_reference(revision=None).is_fully_qualified()) def test_is_under_development(self): # True is returned if the reference is under development. self.assertTrue(make_reference( channel=references.DEVELOPMENT_CHANNEL).is_under_development()) self.assertTrue(make_reference( user='', channel=references.DEVELOPMENT_CHANNEL).is_under_development()) self.assertTrue(make_reference( channel=references.DEVELOPMENT_CHANNEL, revision=0).is_under_development()) def test_is_not_under_development(self): # False is returned if the reference is not under development. self.assertFalse(make_reference(schema='local').is_under_development()) self.assertFalse(make_reference(series='').is_under_development()) self.assertFalse(make_reference(revision=None).is_under_development()) class TestReferenceSimilar(unittest.TestCase): def test_similar_references(self): # True is returned if the references are similar. ref = make_reference() self.assertTrue(ref.similar(make_reference())) self.assertTrue(ref.similar(make_reference( channel=references.DEVELOPMENT_CHANNEL))) self.assertTrue(ref.similar(make_reference(series='utopic'))) self.assertTrue( ref.similar(make_reference(series='trusty', revision=0))) def test_similar_promulgated_references(self): # True is returned if the promulgated references are similar. ref = make_reference(user='') self.assertTrue(ref.similar(ref)) self.assertTrue(ref.similar(make_reference(user='', series='utopic'))) self.assertTrue(ref.similar(make_reference( user='', channel=references.DEVELOPMENT_CHANNEL))) self.assertTrue( ref.similar(make_reference(user='', series='trusty', revision=0))) def test_different_references(self): # False is returned if the references do not share the same schema, # user or name. ref = make_reference() self.assertFalse(ref.similar(make_reference(schema='local'))) self.assertFalse(ref.similar(make_reference(user='who'))) self.assertFalse(ref.similar(make_reference(name='django'))) def test_different_promulgated_references(self): # False is returned if the promulgated references do not share the # same schema, user or name. ref = make_reference(user='') self.assertFalse(ref.similar(make_reference(schema='local', user=''))) self.assertFalse(ref.similar(make_reference(user='who'))) self.assertFalse(ref.similar(make_reference(user='', name='django'))) def test_different_types(self): # A type error is returned if an unsupported type is provided. ref = make_reference() with self.assertRaises(TypeError) as ctx: ref.similar(42) self.assertEqual( 'cannot compare unsupported type int', pyutils.exception_string(ctx.exception)) class TestReferenceFromFullyQualifiedUrl( helpers.ValueErrorTestsMixin, unittest.TestCase): def test_no_schema_error(self): # A ValueError is raised if the URL schema is missing. expected_error = b'URL has no schema: precise/juju-gui' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url('precise/juju-gui') def test_no_url_error(self): # A ValueError is raised if the URL is empty. expected_error = b'URL has no schema: ' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url('') def test_invalid_schema_error(self): # A ValueError is raised if the URL schema is not valid. expected_error = b'URL has invalid schema: http' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'http:precise/juju-gui') def test_invalid_user_name_error(self): # A ValueError is raised if the user name is not valid. expected_error = b'URL has invalid user name: jean:luc' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'cs:~jean:luc/precise/juju-gui') def test_local_user_name_error(self): # A ValueError is raised if a user is specified on a local entity. expected_error = ( b'local entity URL with user name: ' b'local:~jean-luc/precise/juju-gui') with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'local:~jean-luc/precise/juju-gui') def test_invalid_channel_error(self): # A ValueError is raised if the channel is not valid. expected_error = ( b'URL has invalid form: cs:~jean-luc/bad-wolf/wily/juju-gui') with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'cs:~jean-luc/bad-wolf/wily/juju-gui') def test_local_channel_error(self): # A ValueError is raised if a channel is specified on a local entity. expected_error = ( b'local entity URL with channel: ' b'local:development/precise/juju-gui') with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'local:development/precise/juju-gui') def test_invalid_form_error(self): # A ValueError is raised if the URL is not valid. expected_error = ( b'URL has invalid form: cs:~user/development/series/name/what-?') with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'cs:~user/development/series/name/what-?') def test_user_only_error(self): # A ValueError is raised if the URL only includes the user. expected_error = ( b'URL has invalid form: cs:~user') with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url('cs:~user') def test_invalid_series_error(self): # A ValueError is raised if the series is not valid. expected_error = b'URL has invalid series: boo!' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'cs:boo!/juju-gui-42') def test_no_series_error(self): # A ValueError is raised if the series is not specified. expected_error = b'URL has invalid form: cs:~user/juju-gui-42' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'cs:~user/juju-gui-42') def test_no_revision_error(self): # A ValueError is raised if the entity revision is missing. expected_error = b'URL has no revision: cs:series/name' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url('cs:series/name') def test_invalid_revision_error(self): # A ValueError is raised if the charm or bundle revision is not valid. expected_error = b'URL has invalid revision: revision' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'cs:series/name-revision') def test_invalid_name_error(self): # A ValueError is raised if the entity name is not valid. expected_error = b'URL has invalid name: not:valid' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url( 'cs:precise/not:valid-42') def test_success(self): # References are correctly instantiated by parsing the fully qualified # URL. tests = ( ('cs:~myuser/precise/juju-gui-42', make_reference()), ('cs:trusty/juju-gui-42', make_reference(user='', series='trusty')), ('local:precise/juju-gui-42', make_reference(schema='local', user='')), ('cs:~myuser/development/precise/juju-gui-42', make_reference(channel=references.DEVELOPMENT_CHANNEL)), ('cs:development/trusty/juju-gui-42', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='trusty')), ) for url, expected_ref in tests: ref = references.Reference.from_fully_qualified_url(url) self.assertEqual(expected_ref, ref) class TestReferenceFromString( helpers.ValueErrorTestsMixin, unittest.TestCase): def test_invalid_schema_error(self): # A ValueError is raised if the URL schema is not valid. expected_error = b'URL has invalid schema: http' with self.assert_value_error(expected_error): references.Reference.from_string('http:precise/juju-gui') def test_no_url_error(self): # A ValueError is raised if the URL is empty. expected_error = b'URL has no schema: ' with self.assert_value_error(expected_error): references.Reference.from_fully_qualified_url('') def test_invalid_user_name_error(self): # A ValueError is raised if the user name is not valid. expected_error = b'URL has invalid user name: jean:luc' with self.assert_value_error(expected_error): references.Reference.from_string( 'cs:~jean:luc/precise/juju-gui') def test_local_user_name_error(self): # A ValueError is raised if a user is specified on a local entity. expected_error = ( b'local entity URL with user name: ' b'local:~jean-luc/precise/juju-gui') with self.assert_value_error(expected_error): references.Reference.from_string( 'local:~jean-luc/precise/juju-gui') def test_invalid_channel_error(self): # A ValueError is raised if the channel is not valid. expected_error = ( b'URL has invalid form: cs:~jean-luc/bad-wolf/wily/juju-gui') with self.assert_value_error(expected_error): references.Reference.from_string( 'cs:~jean-luc/bad-wolf/wily/juju-gui') def test_local_channel_error(self): # A ValueError is raised if a channel is specified on a local entity. expected_error = ( b'local entity URL with channel: ' b'local:development/precise/juju-gui') with self.assert_value_error(expected_error): references.Reference.from_string( 'local:development/precise/juju-gui') def test_invalid_form_error(self): # A ValueError is raised if the URL is not valid. expected_error = b'URL has invalid form: cs:~user/series/name/what-?' with self.assert_value_error(expected_error): references.Reference.from_string( 'cs:~user/series/name/what-?') def test_user_only_error(self): # A ValueError is raised if the URL only includes the user. expected_error = ( b'URL has invalid form: cs:~user') with self.assert_value_error(expected_error): references.Reference.from_string('cs:~user') def test_invalid_series_error(self): # A ValueError is raised if the series is not valid. expected_error = b'URL has invalid series: boo!' with self.assert_value_error(expected_error): references.Reference.from_string( 'cs:boo!/juju-gui-42') def test_invalid_name_error(self): # A ValueError is raised if the entity name is not valid. expected_error = b'URL has invalid name: not:valid' with self.assert_value_error(expected_error): references.Reference.from_string( 'cs:precise/not:valid-42') def test_success(self): # References are correctly instantiated by parsing the URL. tests = ( # Fully qualified. ('cs:~myuser/precise/juju-gui-42', make_reference()), # Fully qualified and promulgated. ('cs:trusty/juju-gui-42', make_reference(user='', series='trusty')), # Fully qualified local. ('local:precise/juju-gui-42', make_reference(schema='local', user='')), # No schema. ('~myuser/precise/juju-gui-42', make_reference()), # No schema and promulgated. ('trusty/juju-gui-42', make_reference(user='', series='trusty')), # No series. ('cs:~myuser/juju-gui-42', make_reference(series='')), # No series and promulgated. ('cs:juju-gui-42', make_reference(user='', series='')), # No revision. ('cs:~myuser/precise/juju-gui', make_reference(revision=None)), # No revision and not hyphen in name. ('cs:~myuser/precise/django', make_reference(name='django', revision=None)), # No revision and promulgated. ('cs:precise/juju-gui', make_reference(user='', revision=None)), # No schema, series and revision. ('~myuser/juju-gui', make_reference(series='', revision=None)), # No schema, series and revision, promulgated. ('juju-gui', make_reference(user='', series='', revision=None)), # Fully qualified (under development). ('cs:~myuser/development/precise/juju-gui-42', make_reference(channel=references.DEVELOPMENT_CHANNEL)), # Fully qualified and promulgated (under development). ('cs:development/trusty/juju-gui-42', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='trusty')), # No schema (under development). ('~myuser/development/precise/juju-gui-42', make_reference(channel=references.DEVELOPMENT_CHANNEL)), # No schema and promulgated (under development). ('development/trusty/juju-gui-42', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='trusty')), # No series (under development). ('cs:~myuser/development/juju-gui-42', make_reference(channel=references.DEVELOPMENT_CHANNEL, series='')), # No series and promulgated (under development). ('cs:development/juju-gui-42', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='')), # No revision (under development). ('cs:~myuser/development/precise/juju-gui', make_reference(channel=references.DEVELOPMENT_CHANNEL, revision=None)), # No revision and not hyphen in name (under development). ('cs:~myuser/development/precise/django', make_reference(channel=references.DEVELOPMENT_CHANNEL, name='django', revision=None)), # No revision and promulgated (under development). ('cs:development/precise/juju-gui', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, revision=None)), # No schema, series and revision (under development). ('~myuser/development/juju-gui', make_reference(channel=references.DEVELOPMENT_CHANNEL, series='', revision=None)), # No schema, series and revision, promulgated (under development). ('development/juju-gui', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='', revision=None)), ) for url, expected_ref in tests: ref = references.Reference.from_string(url) self.assertEqual(expected_ref, ref) class TestReferenceFromJujucharmsUrl( helpers.ValueErrorTestsMixin, unittest.TestCase): def test_invalid_form(self): # A ValueError is raised if the URL is not valid. expected_error = b'invalid charm or bundle URL: bad wolf' with self.assert_value_error(expected_error): references.Reference.from_jujucharms_url('bad wolf') def test_invalid_channel(self): # A ValueError is raised if the channel is not valid. url = 'u/myuser/bad-wolf/django/trusty/42' expected_error = b'invalid charm or bundle URL: ' + url.encode('utf-8') with self.assert_value_error(expected_error): references.Reference.from_jujucharms_url(url) def test_success(self): # A reference is correctly created from a jujucharms.com identifier or # complete URL. tests = ( # Check with both user and revision. ('u/myuser/mediawiki/42', make_reference(series='', name='mediawiki')), ('/u/myuser/mediawiki/42', make_reference(series='', name='mediawiki')), ('u/myuser/django-scalable/42/', make_reference(series='', name='django-scalable')), ('{}u/myuser/mediawiki/42'.format(references.JUJUCHARMS_URL), make_reference(series='', name='mediawiki')), ('{}u/myuser/mediawiki/42/'.format(references.JUJUCHARMS_URL), make_reference(series='', name='mediawiki')), ('u/myuser/django-scalable/bundle/42/', make_reference(series='bundle', name='django-scalable')), ('{}u/myuser/django/bundle/0/'.format(references.JUJUCHARMS_URL), make_reference(series='bundle', name='django', revision=0)), # Check under development. ('u/myuser/development/mediawiki/42', make_reference(channel=references.DEVELOPMENT_CHANNEL, series='', name='mediawiki')), ('/development/mediawiki/42', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='', name='mediawiki')), ('development/django-scalable', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='', name='django-scalable', revision=None)), ('development/django-scalable/bundle', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='bundle', name='django-scalable', revision=None)), ('u/myuser/development/mediawiki/wily', make_reference(channel=references.DEVELOPMENT_CHANNEL, series='wily', name='mediawiki', revision=None)), ('/development/mediawiki/trusty/0', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='trusty', name='mediawiki', revision=0)), ('{}u/myuser/development/hp/42'.format(references.JUJUCHARMS_URL), make_reference(channel=references.DEVELOPMENT_CHANNEL, series='', name='hp')), ('{}development/mediawiki/42/'.format(references.JUJUCHARMS_URL), make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='', name='mediawiki')), ('development/openstack-base/bundle/47/', make_reference(user='', channel=references.DEVELOPMENT_CHANNEL, series='bundle', name='openstack-base', revision=47)), # Check without revision. ('u/myuser/mediawiki', make_reference(series='', name='mediawiki', revision=None)), ('/u/myuser/wordpress', make_reference(series='', name='wordpress', revision=None)), ('u/myuser/mediawiki/', make_reference(series='', name='mediawiki', revision=None)), ('{}u/myuser/django'.format(references.JUJUCHARMS_URL), make_reference(series='', name='django', revision=None)), ('{}u/myuser/mediawiki/'.format(references.JUJUCHARMS_URL), make_reference(series='', name='mediawiki', revision=None)), ('{}u/myuser/mediawiki/bundle/'.format(references.JUJUCHARMS_URL), make_reference(series='bundle', name='mediawiki', revision=None)), # Check without the user. ('rails-single/42', make_reference(user='', series='', name='rails-single')), ('/mediawiki/42', make_reference(user='', series='', name='mediawiki')), ('rails-scalable/42/', make_reference(user='', series='', name='rails-scalable')), ('{}mediawiki/42'.format(references.JUJUCHARMS_URL), make_reference(user='', series='', name='mediawiki')), ('{}django/42/'.format(references.JUJUCHARMS_URL), make_reference(user='', series='', name='django')), ('{}django/bundle/42/'.format(references.JUJUCHARMS_URL), make_reference(user='', series='bundle', name='django')), # Check without user and revision. ('mediawiki', make_reference(user='', series='', name='mediawiki', revision=None)), ('/wordpress', make_reference(user='', series='', name='wordpress', revision=None)), ('mediawiki/', make_reference(user='', series='', name='mediawiki', revision=None)), ('{}django'.format(references.JUJUCHARMS_URL), make_reference(user='', series='', name='django', revision=None)), ('{}mediawiki/'.format(references.JUJUCHARMS_URL), make_reference(user='', series='', name='mediawiki', revision=None)), ('mediawiki/bundle', make_reference(user='', series='bundle', name='mediawiki', revision=None)), ('{}django/bundle/'.format(references.JUJUCHARMS_URL), make_reference(user='', series='bundle', name='django', revision=None)), # Check with charm series. ('mediawiki/trusty/0', make_reference(user='', series='trusty', name='mediawiki', revision=0)), ('/wordpress/precise', make_reference(user='', series='precise', name='wordpress', revision=None)), ('u/who/rails/vivid', make_reference(user='who', series='vivid', name='rails', revision=None)), ) for url, expected_ref in tests: ref = references.Reference.from_jujucharms_url(url) self.assertEqual(expected_ref, ref) jujubundlelib-0.4.1/jujubundlelib/tests/__init__.py0000664000175000017500000000000012620167221024040 0ustar frankbanfrankban00000000000000jujubundlelib-0.4.1/jujubundlelib/tests/test_validation.py0000664000175000017500000006351612627011034025514 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import pprint from jujubundlelib import validation # Define validation tests as a dict mapping test names to tuples of type # (expected_errors, bundle) where expected_errors is a list of the errors # returned by validation.validate called passing the bundle content. _validation_tests = { # Valid bundle. 'test_valid_bundle': ( [], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, }, ), 'test_valid_bundle_series': ( [], { 'series': 'precise', 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, }, ), 'test_valid_bundle_exposed': ( [], { 'series': 'wily', 'services': { 'django': {'charm': 'cs:trusty/django-42', 'expose': True}, }, }, ), 'test_valid_bundle_unexposed': ( [], { 'series': 'wily', 'services': { 'django': {'charm': 'cs:trusty/django-42', 'expose': False}, }, }, ), 'test_valid_bundle_machines': ( [], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': ['1'], }, }, 'machines': {1: {}}, }, ), 'test_valid_bundle_machines_none': ( [], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': ['1'], }, }, 'machines': {1: None}, }, ), 'test_valid_bundle_relations': ( [], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, 'haproxy': {'charm': 'cs:trusty/haproxy-47', 'num_units': 0}, }, 'relations': [('django:http', 'haproxy:http')], }, ), 'test_valid_bundle_all_sections': ( [], { 'series': 'precise', 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': ['1'], }, 'haproxy': {'charm': 'cs:trusty/haproxy-47', 'num_units': 0}, }, 'machines': {1: {}}, 'relations': [('django:http', 'haproxy:http')], }, ), 'test_valid_bundle_partial_service_url': ( [], { 'services': { 'django': {'charm': 'django', 'num_units': 1}, 'haproxy': {'charm': 'trusty/haproxy', 'num_units': 0}, }, }, ), 'test_valid_bundle_string_placement': ( [], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': '0', }, }, 'machines': {0: {}}, }, ), 'test_valid_bundle_no_num_units': ( [], { 'services': { 'django': {'charm': 'cs:trusty/django-42'}, }, }, ), 'test_valid_bundle_constraints': ( [], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'constraints': 'cpu-cores=8 arch=amd64', }, }, }, ), 'test_valid_bundle_storage_constraints': ( [], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'storage': {'data': 'ebs,10G', 'cache': 'ebs-ssd'}, }, }, }, ), 'test_valid_bundle_options': ( [], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'options': {'key1': 47, 'key2': 'val2'}, }, }, }, ), 'test_valid_unit_placement': ( [], { 'services': { 'django': { 'charm': 'trusty/django', 'num_units': 3, 'to': ['kvm:0', '1'], }, 'rails': {'charm': 'rails', 'num_units': 1}, 'haproxy': { 'charm': 'haproxy', 'num_units': 1, 'to': 'rails/0', }, 'memcached': { 'charm': 'cs:memcached-42', 'num_units': 2, 'to': ['lxc:1', 'new'], } }, 'machines': { '0': { 'series': 'trusty', 'constraints': 'mem=8000 cpu-cores=4', 'annotations': { 'foo': 'bar', }, }, '1': {}, }, }, ), 'test_valid_service_placement_v4_bundle': ( [], { 'services': { 'django': { 'charm': 'trusty/django', 'num_units': 1, 'to': ['kvm:2'], }, }, 'machines': { '2': {'series': 'precise'}, }, }, ), 'test_valid_relations': ( [], { 'services': { 'django': { 'charm': 'trusty/django', 'num_units': 3, 'to': ['kvm:0', '1'], }, 'rails': {'charm': 'rails', 'num_units': 1}, 'haproxy': { 'charm': 'haproxy', 'num_units': 1, 'to': 'rails/0', }, 'memcached': { 'charm': 'cs:memcached-42', 'num_units': 2, 'to': ['lxc:1', 'new'], } }, 'machines': { '0': { 'series': 'trusty', 'constraints': 'mem=8000 cpu-cores=4', 'annotations': { 'foo': 'bar', }, }, '1': {}, }, 'relations': [ ['django:web', 'haproxy:web'], ['rails:cache', 'memcached:cache'], ['haproxy', 'rails'], ['django:cache', 'memcached'], ], }, ), # Invalid bundle. 'test_invalid_bundle_int': ( ['bundle does not appear to be a bundle'], 42, ), 'test_invalid_bundle_string': ( ['bundle does not appear to be a bundle'], 'invalid', ), # Invalid bundle sections. 'test_empty_bundle': ( ['bundle does not define any services'], {}, ), 'test_no_services_section': ( ['bundle does not define any services'], {'services': {}}, ), 'test_invalid_services_section': ( ['services spec does not appear to be well-formed'], {'services': 42}, ), 'test_invalid_machines_section': ( ['machines spec does not appear to be well-formed'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, 'machines': 42, }, ), 'test_invalid_machines_section_non_digit_string': ( ['machines spec identifiers must be digits'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, 'machines': {'foo': {}}, }, ), 'test_invalid_machines_section_non_digit_tuple': ( ['machines spec identifiers must be digits'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, 'machines': {'1': {}, ('foo'): {}}, }, ), 'test_invalid_relations_section_int': ( ['relations spec does not appear to be well-formed'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, 'relations': 42, }, ), 'test_invalid_relations_section_string': ( ['relations spec does not appear to be well-formed'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, 'relations': 'invalid', }, ), # Invalid series. 'test_invalid_bundle_series_type': ( ['bundle series must be a string, found []'], { 'series': [], 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, }, ), 'test_invalid_bundle_series_format': ( ['bundle has invalid series not@valid'], { 'series': 'not@valid', 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, }, ), 'test_invalid_bundle_series_bundle': ( ['bundle series must specify a charm series'], { 'series': 'bundle', 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, }, }, ), # Invalid services. 'test_invalid_service_name': ( ['service name 42 must be a string'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, 42: {'charm': 'cs:trusty/bad-charm', 'num_units': 1}, }, }, ), 'test_invalid_service_too_many_units': ( ['too many units placed for service django'], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 2, 'to': ['1', 'lxc:1', 'new'] }, }, 'machines': {'1': {}}, }, ), 'test_invalid_service_machine_not_referred_to': ( ['machine 1 not referred to by a placement directive', 'machine 2 not referred to by a placement directive'], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 2, 'to': 'new', }, }, 'machines': {'1': {}, '2': {}}, }, ), 'test_invalid_service_charm_url': ( ['no charm specified for service django', 'empty charm specified for service haproxy', 'invalid charm specified for service mysql: 42', 'invalid charm specified for service rails: ' 'URL has invalid schema: bad'], { 'services': { 'django': {'num_units': 1}, 'haproxy': {'charm': '', 'num_units': 1}, 'mysql': {'charm': 42, 'num_units': 1}, 'rails': {'charm': 'bad:wolf', 'num_units': 1}, }, }, ), 'test_invalid_service_charm_reference': ( ['local charms not allowed for service mysql: local:mysql', 'bundle cannot be used as charm for service rails: cs:bundle/rails'], { 'services': { 'mysql': {'charm': 'local:mysql', 'num_units': 1}, 'rails': {'charm': 'bundle/rails', 'num_units': 1}, }, }, ), 'test_invalid_service_num_units': ( ['num_units for service django must be a digit', 'num_units for service haproxy must be a digit', 'num_units -47 for service mysql must be a positive digit'], { 'services': { 'django': {'charm': 'django', 'num_units': 'bad-wolf'}, 'haproxy': {'charm': 'haproxy', 'num_units': {}}, 'mysql': {'charm': 'mysql', 'num_units': -47}, }, }, ), 'test_invalid_service_constraints': ( ['service django has invalid constraints 47', 'service memcached has invalid constraints {}', 'service haproxy has invalid constraints bad wolf', 'service rails has invalid constraints foo=bar'], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 2, 'constraints': 47, }, 'memcached': {'charm': 'memcached', 'constraints': {}}, 'haproxy': {'charm': 'haproxy', 'constraints': 'bad wolf'}, 'rails': {'charm': 'rails', 'constraints': 'foo=bar'}, }, }, ), 'test_invalid_service_storage_constraints': ( ['service django has invalid storage constraints 47', 'service memcached has invalid storage constraints []', 'service haproxy has invalid storage constraints bad wolf', 'service rails has invalid storage constraints foo=bar'], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 2, 'storage': 47, }, 'memcached': {'charm': 'memcached', 'storage': []}, 'haproxy': {'charm': 'haproxy', 'storage': 'bad wolf'}, 'rails': {'charm': 'rails', 'storage': 'foo=bar'}, }, }, ), 'test_invalid_service_options': ( ['service django has malformed options', 'service haproxy has malformed options', 'service rails has malformed options'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'options': 47}, 'rails': {'charm': 'trusty/rails', 'options': 'bad wolf'}, 'haproxy': {'charm': 'cs:trusty/haproxy', 'options': []}, }, }, ), 'test_invalid_service_annotations': ( ['service django has invalid annotations 47', 'service rails has invalid annotations bad wolf', 'service haproxy has invalid annotations: keys must be strings'], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'annotations': 47, }, 'rails': { 'charm': 'trusty/rails', 'annotations': 'bad wolf', }, 'haproxy': { 'charm': 'cs:trusty/haproxy', 'annotations': {'key1': 'value1', None: 42}, }, }, }, ), 'test_invalid_service_expose_flag': ( ['invalid expose value for service django'], { 'series': 'wily', 'services': { 'django': {'charm': 'cs:trusty/django-42', 'expose': 'bad'}, }, }, ), # Invalid unit placement. 'test_invalid_machine_placement_v3_bundle': ( ['too many units placed for service django', 'invalid container bad for placement bad:wolf', 'legacy bundles may not place units on machines other than 0', 'invalid placement 47: placement must be a string'], { 'services': { 'django': {'charm': 'django', 'to': 'bad:wolf'}, 'rails': {'charm': 'rails', 'num_units': 1, 'to': '1'}, 'haproxy': {'charm': 'haproxy', 'num_units': 1, 'to': 47}, }, }, ), 'test_invalid_machine_placement_v4_bundle': ( ['invalid container bad for placement bad:wolf', 'placement 3 refers to a non-existent machine 3', 'too many units placed for service haproxy'], { 'services': { 'django': { 'charm': 'django', 'num_units': 2, 'to': ['1', 'bad:wolf'], }, 'rails': {'charm': 'rails', 'num_units': 1, 'to': '3'}, 'haproxy': { 'charm': 'haproxy', 'num_units': 2, 'to': ['1', 'lxc:2', '2'], }, }, 'machines': {1: {}, '2': {}} }, ), 'test_invalid_service_placement_v3_bundle': ( ['placement no-such refers to non-existent service no-such', 'placement no-such=0 refers to non-existent service no-such', 'unit in placement rails=no-such must be digit', 'placement haproxy=2 specifies a unit greater than the units in ' 'service haproxy'], { 'services': { 'django': { 'charm': 'utopic/django', 'num_units': 1, 'to': 'no-such', }, 'rails': {'charm': 'rails', 'num_units': 1, 'to': 'no-such=0'}, 'haproxy': { 'charm': 'haproxy', 'num_units': 1, 'to': 'rails=no-such', }, 'memcached': { 'charm': 'cs:memcached-42', 'num_units': 2, 'to': 'haproxy=2', } }, }, ), 'test_invalid_service_placement_v4_bundle': ( ['charm cs:utopic/django cannot be deployed to machine with different ' 'series trusty', 'placement no-such refers to non-existent service no-such', 'placement no-such/0 refers to non-existent service no-such', 'unit in placement rails/invalid must be digit', 'placement haproxy/2 specifies a unit greater than the units in ' 'service haproxy'], { 'services': { 'django': { 'charm': 'utopic/django', 'num_units': 3, 'to': ['no-such', '0', 'lxc:1'], }, 'rails': {'charm': 'rails', 'num_units': 1, 'to': 'no-such/0'}, 'haproxy': { 'charm': 'haproxy', 'num_units': 1, 'to': 'rails/invalid', }, 'memcached': { 'charm': 'cs:memcached-42', 'num_units': 2, 'to': 'haproxy/2', } }, 'machines': { '0': { 'series': 'trusty', 'constraints': 'mem=8000', 'annotations': { 'foo': 'bar', }, }, '1': {}, }, }, ), 'test_invalid_service_placement_num_units': ( ['machine 0 not referred to by a placement directive', 'placement 42 refers to a non-existent machine 42', 'num_units for service rails must be a digit', 'invalid container no-such for placement no-such:1'], { 'services': { 'django': { 'charm': 'utopic/django', 'num_units': 3, 'to': ['42', 'lxc:1'], }, 'rails': {'charm': 'rails', 'num_units': 'invalid'}, 'haproxy': { 'charm': 'haproxy', 'num_units': 1, 'to': 'rails/0', }, 'memcached': { 'charm': 'cs:memcached-42', 'num_units': 2, 'to': 'no-such:1', } }, 'machines': { '0': { 'series': 'trusty', 'constraints': 'mem=8000', 'annotations': { 'foo': 'bar', }, }, '1': {}, }, }, ), # Invalid machines. 'test_invalid_machines_number': ( ['machine -1 has an invalid id, must be positive digit', 'machine -1 not referred to by a placement directive', 'machine -47 has an invalid id, must be positive digit', 'machine -47 not referred to by a placement directive'], { 'services': { 'django': {'charm': 'utopic/django', 'num_units': 3}, }, 'machines': { -1: { 'series': 'trusty', 'constraints': 'mem=8000', 'annotations': { 'foo': 'bar', }, }, '-47': {}, }, }, ), 'test_invalid_machines_type': ( ['machine 1 does not appear to be well-formed'], { 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': ['1'], }, }, 'machines': {1: 42}, }, ), 'test_invalid_machines_constraints': ( ['machine 0 has invalid constraints 47', 'machine 1 has invalid constraints invalid', 'machine 2 has invalid constraints no-such=exterminate'], { 'services': { 'rails': { 'charm': 'rails', 'num_units': 3, 'to': ['0', '1', '2'], }, }, 'machines': { '0': {'constraints': 47}, 1: {'series': 'trusty', 'constraints': 'invalid'}, '2': {'constraints': 'no-such=exterminate'}, }, }, ), 'test_invalid_machines_series': ( ['machine 0 series must be a string, found 42', 'machine 1 has invalid series no:such', 'machine 2 series must specify a charm series'], { 'services': { 'rails': { 'charm': 'precise/rails', 'num_units': 3, 'to': ['0', '1', 'lxc:2'], }, }, 'machines': { '0': {'series': 42, 'constraints': 'arch=i386'}, 1: {'series': 'no:such', 'annotations': {'key1': 'val1'}}, '2': {'series': 'bundle', 'annotations': {}}, }, }, ), 'test_invalid_machines_annotations': ( ['machine 0 has invalid annotations 42', 'machine 1 has invalid annotations invalid', 'machine 2 has invalid annotations: keys must be strings'], { 'services': { 'rails': { 'charm': 'precise/rails', 'num_units': 3, 'to': ['0', '1', 'lxc:2'], }, }, 'machines': { '0': {'annotations': 42}, 1: {'annotations': 'invalid'}, '2': {'annotations': {'key1': 'value', 42: 'value'}}, }, }, ), 'test_invalid_machines_multiple_errors': ( ['machine 0 has invalid annotations exterminate', 'machine 0 series must specify a charm series', 'machine 1 has invalid annotations 47', 'machine 1 series must be a string, found 42', 'machine 2 has invalid constraints we=are=the=borg'], { 'services': { 'rails': { 'charm': 'precise/rails', 'num_units': 3, 'to': ['0', '1', 'lxc:2'], }, }, 'machines': { '0': {'series': 'bundle', 'annotations': 'exterminate'}, 1: {'series': 42, 'constraints': '', 'annotations': 47}, '2': {'constraints': 'we=are=the=borg'}, }, }, ), # Invalid relations. 'test_invalid_relations': ( ['relation 42 is malformed', 'relation 47 -> django:db has malformed endpoint 47', 'relation bad wolf is malformed', 'relation haproxy:web -> {} has malformed endpoint {}', 'relation mysql:db -> rails:db endpoint rails:db refers to a ' 'non-existent service rails', 'relation no-such -> django endpoint no-such refers to a ' 'non-existent service no-such'], { 'services': { 'django': {'charm': 'cs:trusty/django-42', 'num_units': 1}, 'mysql': {'charm': 'trusty/mysql', 'num_units': 1}, 'haproxy': {'charm': 'cs:trusty/haproxy', 'num_units': 1}, }, 'relations': [ 42, [47, 'django:db'], ['mysql:db', 'django:db'], ['mysql:db', 'rails:db'], ['haproxy:web', {}], ['haproxy', 'django'], ['no-such', 'django'], 'bad wolf', ], }, ), } def test_validate(): """Test bundle validation. Single tests are generated using the _validation_tests dict. """ for about, params in _validation_tests.items(): expected_errors, bundle = params yield _make_bundle_validation_check(about), expected_errors, bundle def _make_bundle_validation_check(about): """Generate and return an error test callable.""" def inner(expected_errors, bundle): expected_errors = sorted(expected_errors) errors = sorted(validation.validate(bundle)) msg = ( 'error mismatch\nbundle:\n{}\nerrors:\nexpected {}\nobtained {}' ''.format(pprint.pformat(bundle), expected_errors, errors)) assert expected_errors == errors, msg inner.description = about return inner jujubundlelib-0.4.1/jujubundlelib/tests/test_changeset.py0000664000175000017500000007412312627011034025317 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import unittest from jujubundlelib import changeset class TestChangeSet(unittest.TestCase): def setUp(self): self.cs = changeset.ChangeSet({ 'services': {}, 'machines': {}, 'relations': {}, 'series': 'trusty', }) def test_send_receive(self): self.cs.send('foo') self.cs.send('bar') self.assertEqual(['foo', 'bar'], self.cs.recv()) self.assertEqual([], self.cs.recv()) def test_is_legacy_bundle(self): self.assertFalse(self.cs.is_legacy_bundle()) cs = changeset.ChangeSet({'services': {}}) self.assertTrue(cs.is_legacy_bundle()) class TestParse(unittest.TestCase): def handler1(self, changeset): for i in range(3): changeset.send((1, i)) return self.handler2 def handler2(self, changeset): for i in range(3): changeset.send((2, i)) return None def test_parse(self): bundle = { 'services': {}, 'machines': {}, 'relations': {}, 'series': 'trusty', } changes = list(changeset.parse(bundle, handler=self.handler1)) self.assertEqual( [ (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2), ], changes, ) def test_parse_nothing(self): bundle = {'services': {}} self.assertEqual([], list(changeset.parse(bundle))) class TestHandleServices(unittest.TestCase): def test_handler(self): cs = changeset.ChangeSet({ # Use an ordered dict so that changes' ids can be predicted # deterministically. 'services': { 'django': { 'charm': 'cs:trusty/django-42', }, 'mysql-master': { 'charm': 'cs:utopic/mysql-47', 'expose': False, 'constraints': 'cpu-cores=4 mem=42G', }, 'mysql-slave': { 'charm': 'cs:utopic/mysql-47', 'options': { 'key1': 'value1', 'key2': 'value2', }, 'storage': { 'data': 'ebs,10G', 'cache': 'ebs-ssd', }, }, 'haproxy': { 'charm': 'cs:trusty/haproxy-5', 'expose': True, 'annotations': { 'gui-x': 100, 'gui-y': 100, }, }, } }) handler = changeset.handle_services(cs) self.assertEqual(changeset.handle_machines, handler) self.assertEqual( [ { 'id': 'addCharm-0', 'method': 'addCharm', 'args': ['cs:trusty/django-42'], 'requires': [], }, { 'id': 'deploy-1', 'method': 'deploy', 'args': ['$addCharm-0', 'django', {}, '', {}], 'requires': ['addCharm-0'], }, { 'id': 'addCharm-2', 'method': 'addCharm', 'args': ['cs:trusty/haproxy-5'], 'requires': [], }, { 'id': 'deploy-3', 'method': 'deploy', 'args': ['$addCharm-2', 'haproxy', {}, '', {}], 'requires': ['addCharm-2'], }, { 'id': 'expose-4', 'method': 'expose', 'args': ['$deploy-3'], 'requires': ['deploy-3'], }, { 'id': 'setAnnotations-5', 'method': 'setAnnotations', 'args': [ '$deploy-3', 'service', {'gui-x': 100, 'gui-y': 100}, ], 'requires': ['deploy-3'], }, { 'id': 'addCharm-6', 'method': 'addCharm', 'args': ['cs:utopic/mysql-47'], 'requires': [], }, { 'id': 'deploy-7', 'method': 'deploy', 'args': [ '$addCharm-6', 'mysql-master', {}, 'cpu-cores=4 mem=42G', {}, ], 'requires': ['addCharm-6'], }, { 'id': 'deploy-8', 'method': 'deploy', 'args': [ '$addCharm-6', 'mysql-slave', {'key1': 'value1', 'key2': 'value2'}, '', {'data': 'ebs,10G', 'cache': 'ebs-ssd'}, ], 'requires': ['addCharm-6'], }, ], cs.recv()) def test_no_services(self): cs = changeset.ChangeSet({'services': {}}) changeset.handle_services(cs) self.assertEqual([], cs.recv()) class TestHandleMachines(unittest.TestCase): def test_handler(self): cs = changeset.ChangeSet({ # Use an ordered dict so that changes' ids can be predicted # deterministically. 'machines': { '1': {'series': 'vivid'}, '2': {}, '42': {'constraints': {'cpu-cores': 4}}, '23': {'annotations': {'foo': 'bar'}}, } }) handler = changeset.handle_machines(cs) self.assertEqual(changeset.handle_relations, handler) self.assertEqual( [ { 'id': 'addMachines-0', 'method': 'addMachines', 'args': [{'constraints': '', 'series': 'vivid'}], 'requires': [], }, { 'id': 'addMachines-1', 'method': 'addMachines', 'args': [{'constraints': '', 'series': ''}], 'requires': [], }, { 'id': 'addMachines-2', 'method': 'addMachines', 'args': [{'constraints': '', 'series': ''}], 'requires': [], }, { 'id': 'setAnnotations-3', 'method': 'setAnnotations', 'args': [ '$addMachines-2', 'machine', {'foo': 'bar'}, ], 'requires': ['addMachines-2'], }, { 'id': 'addMachines-4', 'method': 'addMachines', 'args': [{'constraints': {'cpu-cores': 4}, 'series': ''}], 'requires': [], }, ], cs.recv()) def test_no_machines(self): cs = changeset.ChangeSet({'services': {}}) changeset.handle_machines(cs) self.assertEqual([], cs.recv()) def test_none_machine(self): cs = changeset.ChangeSet({'machines': {42: None}}) changeset.handle_machines(cs) self.assertEqual([{ 'id': 'addMachines-0', 'method': 'addMachines', 'args': [{'constraints': '', 'series': ''}], 'requires': [], }], cs.recv()) class TestHandleRelations(unittest.TestCase): def test_handler(self): cs = changeset.ChangeSet({ 'services': { 'django': { 'charm': 'cs:trusty/django-42', }, 'mysql': { 'charm': 'cs:utopic/mysql-47', }, }, 'relations': [ ['mysql:foo', 'django:bar'], ], }) cs.services_added = { 'django': 'deploy-1', 'mysql': 'deploy-3', } handler = changeset.handle_relations(cs) self.assertEqual(changeset.handle_units, handler) self.assertEqual( [ { 'id': 'addRelation-0', 'method': 'addRelation', 'args': ['$deploy-3:foo', '$deploy-1:bar'], 'requires': [ 'deploy-3', 'deploy-1' ], } ], cs.recv() ) def test_no_relations(self): cs = changeset.ChangeSet({'relations': []}) changeset.handle_relations(cs) self.assertEqual([], cs.recv()) class TestHandleUnits(unittest.TestCase): def test_handler(self): cs = changeset.ChangeSet({ 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': '42', }, 'mysql': { 'charm': 'cs:utopic/mysql-47', 'num_units': 0, }, 'haproxy': { 'charm': 'cs:precise/haproxy-0', 'num_units': 2, }, 'rails': { 'charm': 'cs:precise/rails-1', 'num_units': 1, 'to': ['0'], }, }, 'machines': {0: {}, 42: {}}, }) cs.services_added = { 'django': 'deploy-1', 'mysql': 'deploy-2', 'haproxy': 'deploy-3', 'rails': 'deploy-4', } cs.machines_added = { '0': 'addMachines-0', '42': 'addMachines-42', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addMachines-42'], 'requires': ['deploy-1', 'addMachines-42'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-3', None], 'requires': ['deploy-3'], }, { 'id': 'addUnit-2', 'method': 'addUnit', 'args': ['$deploy-3', None], 'requires': ['deploy-3'], }, { 'id': 'addUnit-3', 'method': 'addUnit', 'args': ['$deploy-4', '$addMachines-0'], 'requires': ['deploy-4', 'addMachines-0'], }, ], cs.recv()) def test_no_units(self): cs = changeset.ChangeSet({'services': {}}) changeset.handle_units(cs) self.assertEqual([], cs.recv()) def test_subordinate_service(self): cs = changeset.ChangeSet({'services': {'logger': {'charm': 'logger'}}}) changeset.handle_units(cs) self.assertEqual([], cs.recv()) def test_unit_in_new_machine(self): cs = changeset.ChangeSet({ 'services': { 'django-new': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'new', }, }, 'machines': {}, }) cs.services_added = { 'django-new': 'deploy-1', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addMachines-1', 'method': 'addMachines', 'args': [{}], 'requires': [], }, { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addMachines-1'], 'requires': ['deploy-1', 'addMachines-1'], }, ], cs.recv()) def test_placement_unit_in_service(self): cs = changeset.ChangeSet({ 'services': { 'wordpress': { 'charm': 'cs:utopic/wordpress-0', 'num_units': 3, }, 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 2, 'to': ['wordpress'], }, }, 'machines': {}, }) cs.services_added = { 'django': 'deploy-1', 'wordpress': 'deploy-42', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addUnit-2'], 'requires': ['deploy-1', 'addUnit-2'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-1', '$addUnit-3'], 'requires': ['deploy-1', 'addUnit-3'], }, { 'id': 'addUnit-2', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, { 'id': 'addUnit-3', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, { 'id': 'addUnit-4', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, ], cs.recv()) def test_unit_colocation_to_unit(self): cs = changeset.ChangeSet({ 'services': { 'django-new': { 'charm': 'cs:trusty/django-42', 'num_units': 1, }, 'django-unit': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'django-new/0', }, }, 'machines': {}, }) cs.services_added = { 'django-new': 'deploy-1', 'django-unit': 'deploy-2', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', None], 'requires': ['deploy-1'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-2', '$addUnit-0'], 'requires': ['deploy-2', 'addUnit-0'], }, ], cs.recv()) def test_unit_in_preexisting_machine(self): cs = changeset.ChangeSet({ 'services': { 'django-machine': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': '42', }, }, 'machines': {42: {}}, }) cs.services_added = { 'django-machine': 'deploy-3', } cs.machines_added = { '42': 'addMachines-42', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-3', '$addMachines-42'], 'requires': ['deploy-3', 'addMachines-42'], }, ], cs.recv()) def test_unit_in_new_machine_container(self): cs = changeset.ChangeSet({ 'services': { 'django-new-lxc': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'lxc:new', }, }, 'machines': {}, }) cs.services_added = { 'django-new-lxc': 'deploy-4', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addMachines-1', 'method': 'addMachines', 'args': [{'containerType': 'lxc'}], 'requires': [], }, { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-4', '$addMachines-1'], 'requires': ['deploy-4', 'addMachines-1'], }, ], cs.recv()) def test_unit_colocation_to_container_in_unit(self): cs = changeset.ChangeSet({ 'services': { 'django-new': { 'charm': 'cs:trusty/django-42', 'num_units': 1, }, 'django-unit-lxc': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'lxc:django-new/0', }, }, 'machines': {}, }) cs.services_added = { 'django-new': 'deploy-1', 'django-unit-lxc': 'deploy-5', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.maxDiff = None self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', None], 'requires': ['deploy-1'], }, { 'id': 'addMachines-2', 'method': 'addMachines', 'args': [{ 'containerType': 'lxc', 'parentId': '$addUnit-0', }], 'requires': ['addUnit-0'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-5', '$addMachines-2'], 'requires': ['deploy-5', 'addMachines-2'], }, ], cs.recv()) def test_placement_unit_in_container_in_service(self): cs = changeset.ChangeSet({ 'services': { 'wordpress': { 'charm': 'cs:utopic/wordpress-0', 'num_units': 1, }, 'rails': { 'charm': 'cs:utopic/rails-0', 'num_units': 2, }, 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 3, 'to': ['lxc:wordpress', 'kvm:rails'], }, }, 'machines': {}, }) cs.services_added = { 'django': 'deploy-1', 'wordpress': 'deploy-42', 'rails': 'deploy-47', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addMachines-6', 'method': 'addMachines', 'args': [{ 'containerType': 'lxc', 'parentId': '$addUnit-5', }], 'requires': ['addUnit-5'], }, { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addMachines-6'], 'requires': ['deploy-1', 'addMachines-6'], }, { 'id': 'addMachines-7', 'method': 'addMachines', 'args': [{ 'containerType': 'kvm', 'parentId': '$addUnit-3', }], 'requires': ['addUnit-3'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-1', '$addMachines-7'], 'requires': ['deploy-1', 'addMachines-7'], }, { 'id': 'addMachines-8', 'method': 'addMachines', 'args': [{ 'containerType': 'kvm', 'parentId': '$addUnit-4', }], 'requires': ['addUnit-4'], }, { 'id': 'addUnit-2', 'method': 'addUnit', 'args': ['$deploy-1', '$addMachines-8'], 'requires': ['deploy-1', 'addMachines-8'], }, { 'id': 'addUnit-3', 'method': 'addUnit', 'args': ['$deploy-47', None], 'requires': ['deploy-47'], }, { 'id': 'addUnit-4', 'method': 'addUnit', 'args': ['$deploy-47', None], 'requires': ['deploy-47'], }, { 'id': 'addUnit-5', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, ], cs.recv()) def test_unit_in_preexisting_machine_container(self): cs = changeset.ChangeSet({ 'services': { 'django-machine-lxc': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'lxc:0', }, }, 'machines': {0: {}}, }) cs.services_added = { 'django-machine-lxc': 'deploy-6', } cs.machines_added = { '0': 'addMachines-47', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addMachines-1', 'method': 'addMachines', 'args': [{ 'containerType': 'lxc', 'parentId': '$addMachines-47', }], 'requires': ['addMachines-47'], }, { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-6', '$addMachines-1'], 'requires': ['deploy-6', 'addMachines-1'], }, ], cs.recv()) def test_v3_placement_unit_in_bootstrap_node(self): cs = changeset.ChangeSet({ 'services': { 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': '0', }, }, }) cs.services_added = { 'django': 'deploy-1', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '0'], 'requires': ['deploy-1'], }, ], cs.recv()) def test_v3_placement_unit_in_service(self): cs = changeset.ChangeSet({ 'services': { 'wordpress': { 'charm': 'cs:utopic/wordpress-0', 'num_units': 3, }, 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 2, 'to': ['wordpress', 'wordpress'], }, }, }) cs.services_added = { 'django': 'deploy-1', 'wordpress': 'deploy-42', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addUnit-2'], 'requires': ['deploy-1', 'addUnit-2'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-1', '$addUnit-3'], 'requires': ['deploy-1', 'addUnit-3'], }, { 'id': 'addUnit-2', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, { 'id': 'addUnit-3', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, { 'id': 'addUnit-4', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, ], cs.recv()) def test_v3_placement_unit_in_unit(self): cs = changeset.ChangeSet({ 'services': { 'wordpress': { 'charm': 'cs:utopic/wordpress-0', 'num_units': 1, }, 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'wordpress=0', }, }, }) cs.services_added = { 'django': 'deploy-1', 'wordpress': 'deploy-42', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addUnit-1'], 'requires': ['deploy-1', 'addUnit-1'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, ], cs.recv()) def test_v3_placement_unit_in_lxc_in_service(self): cs = changeset.ChangeSet({ 'services': { 'wordpress': { 'charm': 'cs:utopic/wordpress-0', 'num_units': 1, }, 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'lxc:wordpress', }, }, }) cs.services_added = { 'django': 'deploy-1', 'wordpress': 'deploy-42', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addMachines-2', 'method': 'addMachines', 'args': [{ 'containerType': 'lxc', 'parentId': '$addUnit-1', }], 'requires': ['addUnit-1'], }, { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addMachines-2'], 'requires': ['deploy-1', 'addMachines-2'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, ], cs.recv()) def test_v3_placement_unit_in_lxc_in_unit(self): cs = changeset.ChangeSet({ 'services': { 'wordpress': { 'charm': 'cs:utopic/wordpress-0', 'num_units': 1, }, 'django': { 'charm': 'cs:trusty/django-42', 'num_units': 1, 'to': 'lxc:wordpress=0', }, }, }) cs.services_added = { 'django': 'deploy-1', 'wordpress': 'deploy-42', } handler = changeset.handle_units(cs) self.assertIsNone(handler) self.assertEqual( [ { 'id': 'addMachines-2', 'method': 'addMachines', 'args': [{ 'containerType': 'lxc', 'parentId': '$addUnit-1', }], 'requires': ['addUnit-1'], }, { 'id': 'addUnit-0', 'method': 'addUnit', 'args': ['$deploy-1', '$addMachines-2'], 'requires': ['deploy-1', 'addMachines-2'], }, { 'id': 'addUnit-1', 'method': 'addUnit', 'args': ['$deploy-42', None], 'requires': ['deploy-42'], }, ], cs.recv()) jujubundlelib-0.4.1/jujubundlelib/tests/helpers.py0000664000175000017500000000405012620167221023754 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals from contextlib import contextmanager import os import tempfile import mock import yaml from jujubundlelib import pyutils class BundleFileTestsMixin(object): """Shared methods for testing Juju bundle files.""" bundle_data = { 'series': 'precise', 'services': { 'wordpress': { 'charm': 'cs:trusty/wordpress-42', 'num_units': 1, }, 'mysql': { 'charm': 'cs:trusty/mysql-47', 'num_units': 0, }, }, 'machines': {}, } bundle_content = yaml.safe_dump(bundle_data) def make_bundle_file(self, content=None): """Create a Juju bundle file containing the given contents. If content is None, use the valid bundle contents defined in self.bundle_content. Return the bundle file path. """ bundle_file = tempfile.NamedTemporaryFile(delete=False, suffix='.yaml') self.addCleanup(os.remove, bundle_file.name) if content is None: content = self.bundle_content elif isinstance(content, dict): content = yaml.safe_dump(content) bundle_file.write(content.encode('utf-8')) bundle_file.close() return bundle_file.name def mock_print(): """Mock the builtin print function.""" if pyutils.PY3: return mock.patch('builtins.print') return mock.patch('__builtin__.print') class ValueErrorTestsMixin(object): """Set up some base methods for testing functions raising ValueErrors.""" @contextmanager def assert_value_error(self, error, message=None): """Ensure a ValueError is raised in the context block. Also check that the exception includes the expected error message. """ with self.assertRaises(ValueError) as context_manager: yield self.assertEqual(error, context_manager.exception.args[0], message) jujubundlelib-0.4.1/jujubundlelib/tests/test_cli.py0000664000175000017500000000227112620167221024123 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import unittest from jujubundlelib import cli from jujubundlelib.tests import helpers @helpers.mock_print() class TestGetChangeset(helpers.BundleFileTestsMixin, unittest.TestCase): def test_valid_bundle(self, mock_print): path = self.make_bundle_file() error = cli.get_changeset([path]) self.assertIsNone(error) self.assertTrue(mock_print.called) def test_invalid_bundle(self, mock_print): path = self.make_bundle_file({ 'series': 42, 'services': {'django': {}}, }) expected_error = ( 'bundle series must be a string, found 42\n' 'no charm specified for service django') error = cli.get_changeset([path]) print(error) self.assertEqual(expected_error, error) def test_invalid_yaml(self, mock_print): path = self.make_bundle_file(content=':') error = cli.get_changeset([path]) self.assertEqual( 'error: the provided bundle is not a valid YAML', error) self.assertFalse(mock_print.called) jujubundlelib-0.4.1/jujubundlelib/cli.py0000664000175000017500000000270012620167221021717 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import ( print_function, unicode_literals, ) import argparse import json import sys import yaml import jujubundlelib from jujubundlelib import ( changeset, validation, ) # Retrieve the application version. version = jujubundlelib.get_version() def get_changeset(args): """Dump the changeset objects as JSON, reading the provided bundle YAML. The YAML can be provided either from stdin or by passing a file path as first argument. """ # Parse the arguments. parser = argparse.ArgumentParser(description=get_changeset.__doc__) parser.add_argument( 'infile', nargs='?', type=argparse.FileType('r'), default=sys.stdin, help='path to the bundle YAML file') parser.add_argument( '--version', action='version', version='%(prog)s {}'.format(version)) options = parser.parse_args(args) # Parse the provided YAML file. try: bundle = yaml.safe_load(options.infile) except Exception: return 'error: the provided bundle is not a valid YAML' # Validate the bundle object. errors = validation.validate(bundle) if errors: return '\n'.join(errors) # Dump the changeset to stdout. print('[') for num, change in enumerate(changeset.parse(bundle)): if num: print(',') print(json.dumps(change)) print(']') jujubundlelib-0.4.1/jujubundlelib/utils.py0000664000175000017500000000042012620167221022305 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals def is_legacy_bundle(bundle): """Report whether the bundle uses the legacy (version 3) syntax.""" return 'machines' not in bundle jujubundlelib-0.4.1/jujubundlelib/changeset.py0000664000175000017500000002424012627011034023111 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import copy import itertools import models import utils class ChangeSet(object): """Hold the state for parser handlers. Also expose methods to send and receive changes (usually Python dicts). """ services_added = {} machines_added = {} def __init__(self, bundle): self.bundle = bundle self._changeset = [] self._counter = itertools.count() def send(self, change): """Store a change in this change set.""" self._changeset.append(change) def recv(self): """Return all the collected changes. Changes are stored using self.send(). """ changeset = self._changeset self._changeset = [] return changeset def next_action(self): """Return an incremental integer to be included in the changes ids.""" return next(self._counter) def is_legacy_bundle(self): """Report whether the bundle uses the legacy (version 3) syntax.""" return utils.is_legacy_bundle(self.bundle) def handle_services(changeset): """Populate the change set with addCharm and deploy changes.""" charms = {} for service_name, service in sorted(changeset.bundle['services'].items()): # Add the addCharm record if one hasn't been added yet. if service['charm'] not in charms: record_id = 'addCharm-{}'.format(changeset.next_action()) changeset.send({ 'id': record_id, 'method': 'addCharm', 'args': [service['charm']], 'requires': [], }) charms[service['charm']] = record_id # Add the deploy record for this service. record_id = 'deploy-{}'.format(changeset.next_action()) changeset.send({ 'id': record_id, 'method': 'deploy', 'args': [ '${}'.format(charms[service['charm']]), service_name, service.get('options', {}), service.get('constraints', ''), service.get('storage', {}), ], 'requires': [charms[service['charm']]], }) changeset.services_added[service_name] = record_id # Expose this service if required. if service.get('expose'): changeset.send({ 'id': 'expose-{}'.format(changeset.next_action()), 'method': 'expose', 'args': ['${}'.format(record_id)], 'requires': [record_id], }) # Set the annotations for this service. if 'annotations' in service: changeset.send({ 'id': 'setAnnotations-{}'.format(changeset.next_action()), 'method': 'setAnnotations', 'args': [ '${}'.format(record_id), 'service', service['annotations'], ], 'requires': [record_id], }) return handle_machines def handle_machines(changeset): """Populate the change set with addMachines changes.""" machines = sorted(changeset.bundle.get('machines', {}).items()) for machine_name, machine in machines: if machine is None: # We allow the machine value to be unset in the YAML. machine = {} record_id = 'addMachines-{}'.format(changeset.next_action()) changeset.send({ 'id': record_id, 'method': 'addMachines', 'args': [ { 'series': machine.get('series', ''), 'constraints': machine.get('constraints', ''), }, ], 'requires': [], }) changeset.machines_added[str(machine_name)] = record_id if 'annotations' in machine: changeset.send({ 'id': 'setAnnotations-{}'.format(changeset.next_action()), 'method': 'setAnnotations', 'args': [ '${}'.format(record_id), 'machine', machine['annotations'], ], 'requires': [record_id], }) return handle_relations def handle_relations(changeset): """Populate the change set with addRelation changes.""" for relation in changeset.bundle.get('relations', []): relations = [models.Relation(*i.split(':')) if ':' in i else models.Relation(i, '') for i in relation] changeset.send({ 'id': 'addRelation-{}'.format(changeset.next_action()), 'method': 'addRelation', 'args': [ '${}'.format( changeset.services_added[rel.name]) + (':{}'.format(rel.interface) if rel.interface else '') for rel in relations ], 'requires': [changeset.services_added[rel.name] for rel in relations], }) return handle_units def handle_units(changeset): """Populate the change set with addUnit changes.""" units, records = {}, {} for service_name, service in sorted(changeset.bundle['services'].items()): for i in range(service.get('num_units', 0)): record_id = 'addUnit-{}'.format(changeset.next_action()) unit_name = '{}/{}'.format(service_name, i) records[record_id] = { 'id': record_id, 'method': 'addUnit', 'args': [ '${}'.format(changeset.services_added[service_name]), None, ], 'requires': [changeset.services_added[service_name]], } units[unit_name] = { 'record': record_id, 'service': service_name, 'unit': i, } _handle_units_placement(changeset, units, records) def _handle_units_placement(changeset, units, records): """Ensure that requires and placement directives are taken into account.""" for service_name, service in sorted(changeset.bundle['services'].items()): num_units = service.get('num_units') if num_units is None: # This is a subordinate service. continue placement_directives = service.get('to', []) if not isinstance(placement_directives, (list, tuple)): placement_directives = [placement_directives] if placement_directives and not changeset.is_legacy_bundle(): placement_directives += ( placement_directives[-1:] * (num_units - len(placement_directives))) placed_in_services = {} for i in range(num_units): unit = units['{}/{}'.format(service_name, i)] record = records[unit['record']] if i < len(placement_directives): record = _handle_unit_placement( changeset, units, unit, record, placement_directives[i], placed_in_services) changeset.send(record) def _handle_unit_placement( changeset, units, unit, record, placement_directive, placed_in_services): record = copy.deepcopy(record) if changeset.is_legacy_bundle(): placement = models.parse_v3_unit_placement(placement_directive) else: placement = models.parse_v4_unit_placement(placement_directive) if placement.machine: # The unit is placed on a machine. if placement.machine == 'new': parent_record_id = 'addMachines-{}'.format(changeset.next_action()) options = {} if placement.container_type: options = {'containerType': placement.container_type} changeset.send({ 'id': parent_record_id, 'method': 'addMachines', 'args': [options], 'requires': [], }) else: if changeset.is_legacy_bundle(): record['args'][-1] = '0' return record parent_record_id = changeset.machines_added[placement.machine] if placement.container_type: parent_record_id = _handle_container_placement( changeset, placement, parent_record_id) else: # The unit is placed to a unit or to a service. service = placement.service unit_number = placement.unit if unit_number is None: unit_number = _next_unit_in_service(service, placed_in_services) placement_unit = '{}/{}'.format(service, unit_number) parent_record_id = units[placement_unit]['record'] if placement.container_type: parent_record_id = _handle_container_placement( changeset, placement, parent_record_id) record['requires'].append(parent_record_id) record['args'][-1] = '${}'.format(parent_record_id) return record def _next_unit_in_service(service, placed_in_services): """Return the unit number where to place a unit placed on a service. Receive the service name and a dict mapping service names to the current number of placed units in that service. """ current = placed_in_services.get(service) number = 0 if current is None else current + 1 placed_in_services[service] = number return number def _handle_container_placement(changeset, placement, machine_record_id): container_record_id = 'addMachines-{}'.format(changeset.next_action()) options = { 'containerType': placement.container_type, 'parentId': '${}'.format(machine_record_id), } changeset.send({ 'id': container_record_id, 'method': 'addMachines', 'args': [options], 'requires': [machine_record_id], }) return container_record_id def parse(bundle, handler=handle_services): """Return a generator yielding changes required to deploy the given bundle. The bundle argument is a YAML decoded Python dict. """ changeset = ChangeSet(bundle) while True: handler = handler(changeset) for change in changeset.recv(): yield change if handler is None: break jujubundlelib-0.4.1/jujubundlelib/pyutils.py0000664000175000017500000000160712620167221022666 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. import sys # Report whether we are using Python 3 or Python 2. PY3 = sys.version_info >= (3, 0) def string_class(cls): """Define __unicode__ and __str__ methods on the given class in Python 2. The given class must define a __str__ method returning a unicode string, otherwise a TypeError is raised. Under Python 3, the class is returned as is. """ if not PY3: if '__str__' not in cls.__dict__: raise TypeError('the given class has no __str__ method') cls.__unicode__, cls.__string__ = ( cls.__str__, lambda self: self.__unicode__().encode('utf-8')) return cls def exception_string(exception): """Return the string value of an exception, valid for both python 2 and python 3. """ return exception.args[0].decode('utf-8') jujubundlelib-0.4.1/jujubundlelib/__init__.py0000664000175000017500000000042012630005115022676 0ustar frankbanfrankban00000000000000# Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals VERSION = (0, 4, 1) def get_version(): """Return the Juju Bundle Lib version as a string.""" return '.'.join(map(str, VERSION)) jujubundlelib-0.4.1/README.rst0000664000175000017500000000014112620167221017424 0ustar frankbanfrankban00000000000000=============== Juju Bundle Lib =============== A Python library for working with Juju bundles. jujubundlelib-0.4.1/LICENSE0000664000175000017500000001674412620167221016762 0ustar frankbanfrankban00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. jujubundlelib-0.4.1/docs/0000775000175000017500000000000012630005375016673 5ustar frankbanfrankban00000000000000jujubundlelib-0.4.1/docs/Makefile0000664000175000017500000001520612620167221020335 0ustar frankbanfrankban00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/jujubundlelib.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/jujubundlelib.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/jujubundlelib" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/jujubundlelib" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." jujubundlelib-0.4.1/docs/contributing.rst0000664000175000017500000000004112620167221022125 0ustar frankbanfrankban00000000000000.. include:: ../CONTRIBUTING.rst jujubundlelib-0.4.1/docs/installation.rst0000664000175000017500000000014012620167221022117 0ustar frankbanfrankban00000000000000============ Installation ============ At the command line:: $ pip install juju-bundlelib jujubundlelib-0.4.1/docs/usage.rst0000664000175000017500000000016612620167221020532 0ustar frankbanfrankban00000000000000======== Usage ======== To use the Juju Python library for handling bundles in a project:: import jujubundlelib jujubundlelib-0.4.1/docs/authors.rst0000664000175000017500000000003412620167221021105 0ustar frankbanfrankban00000000000000.. include:: ../AUTHORS.rst jujubundlelib-0.4.1/docs/index.rst0000664000175000017500000000102112620167221020524 0ustar frankbanfrankban00000000000000.. jujubundlelib documentation master file, created by sphinx-quickstart on Tue Jul 9 22:26:36 2013. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to Juju Bundle Library's documentation! =============================================== Contents: .. toctree:: :maxdepth: 2 readme installation usage contributing packaging authors Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` jujubundlelib-0.4.1/docs/readme.rst0000664000175000017500000000003312620167221020654 0ustar frankbanfrankban00000000000000.. include:: ../README.rst jujubundlelib-0.4.1/docs/packaging.rst0000664000175000017500000000410012620167221021342 0ustar frankbanfrankban00000000000000========= Packaging ========= Use the following instructions to create Juju Bundle Lib Debian/Ubuntu packages to be pushed in the Juju Stable PPA (``ppa:juju/stable``). * Install the required packages:: sudo apt-get install debhelper devscripts git python-setuptools ubuntu-dev-tools * Set up your Debian environment variables, for instance:: export DEBEMAIL=francesco.banconi@canonical.com export DEBFULLNAME="Francesco Banconi" * Ensure your SSH and GPG keys for the user above are correctly set up in your machine. Check your ``~/.ssh`` and ``~/.gnupg`` directories. * Retrieve the Juju Bundle Lib tarball from PyPI, e.g.:: wget https://pypi.python.org/packages/source/j/jujubundlelib/jujubundlelib-0.1.7.tar.gz * Rename the resulting archive, so that it can be used to create the packages:: mv jujubundlelib-0.1.7.tar.gz jujubundlelib_0.1.7.orig.tar.gz * Expand the archive:: tar xvBf jujubundlelib_0.1.7.orig.tar.gz * Clone the packaging repository, which includes the Debian files, inside the resulting directory:: git clone git@github.com:juju/juju-bundlelib-packaging.git jujubundlelib-0.1.7/debian * Update the package changelog, ensuring the version in the changelog reflects the PyPI one (in this examples it is 0.1.7) and the distribution is ubuntu+1 (wily atm):: cd jujubundlelib-0.1.7 dch -v=0.1.7-1 --distribution=wily * Commit your changes and push them to the master branch:: cd debian git commit -a -m "Update for version 0.1.7" && git push * Remove the git repository from the debian directory:: rm -rf .git * Build the package and sign it with your GPG key, for instance:: cd .. debuild -S -kXXXXXXX * Move back to the initial directory, where the dsc file has been created:: cd .. * Upload the package to the PPA, and wait for it to build:: for release in "precise trusty vivid wily"; do backportpackage -u ppa:juju/stable -r -d $release -S ~ppa1 -y jujubundlelib_*.dsc done * The building process can be followed at https://launchpad.net/~juju/+archive/ubuntu/stable/+packages jujubundlelib-0.4.1/AUTHORS.rst0000664000175000017500000000014612620167221017621 0ustar frankbanfrankban00000000000000======= Credits ======= Development Lead ---------------- * Juju UI Team jujubundlelib-0.4.1/getchangeset0000775000175000017500000000042112620167221020325 0ustar frankbanfrankban00000000000000#!/usr/bin/env python # Copyright 2015 Canonical Ltd. # Licensed under the AGPLv3, see LICENCE file for details. from __future__ import unicode_literals import sys from jujubundlelib import cli if __name__ == '__main__': sys.exit(cli.get_changeset(sys.argv[1:])) jujubundlelib-0.4.1/PKG-INFO0000664000175000017500000000151412630005375017041 0ustar frankbanfrankban00000000000000Metadata-Version: 1.1 Name: jujubundlelib Version: 0.4.1 Summary: A python library for working with Juju bundles Home-page: https://github.com/juju/juju-bundlelib Author: Juju UI Team Author-email: juju-gui@lists.ubuntu.com License: LGPL3 Description: =============== Juju Bundle Lib =============== A Python library for working with Juju bundles. Keywords: juju bundles Platform: UNKNOWN Classifier: Development Status :: 2 - Pre-Alpha Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) Classifier: Natural Language :: English Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4