Merge "Add broker support for passing app_name"

This commit is contained in:
Zuul 2018-05-09 12:04:57 +00:00 committed by Gerrit Code Review
commit cd835e1a07
20 changed files with 843 additions and 158 deletions

View File

@ -21,6 +21,9 @@ from collections import OrderedDict
from charmhelpers.contrib.amulet.deployment import ( from charmhelpers.contrib.amulet.deployment import (
AmuletDeployment AmuletDeployment
) )
from charmhelpers.contrib.openstack.amulet.utils import (
OPENSTACK_RELEASES_PAIRS
)
DEBUG = logging.DEBUG DEBUG = logging.DEBUG
ERROR = logging.ERROR ERROR = logging.ERROR
@ -271,11 +274,8 @@ class OpenStackAmuletDeployment(AmuletDeployment):
release. release.
""" """
# Must be ordered by OpenStack release (not by Ubuntu release): # Must be ordered by OpenStack release (not by Ubuntu release):
(self.trusty_icehouse, self.trusty_kilo, self.trusty_liberty, for i, os_pair in enumerate(OPENSTACK_RELEASES_PAIRS):
self.trusty_mitaka, self.xenial_mitaka, self.xenial_newton, setattr(self, os_pair, i)
self.yakkety_newton, self.xenial_ocata, self.zesty_ocata,
self.xenial_pike, self.artful_pike, self.xenial_queens,
self.bionic_queens,) = range(13)
releases = { releases = {
('trusty', None): self.trusty_icehouse, ('trusty', None): self.trusty_icehouse,

View File

@ -50,6 +50,13 @@ ERROR = logging.ERROR
NOVA_CLIENT_VERSION = "2" NOVA_CLIENT_VERSION = "2"
OPENSTACK_RELEASES_PAIRS = [
'trusty_icehouse', 'trusty_kilo', 'trusty_liberty',
'trusty_mitaka', 'xenial_mitaka', 'xenial_newton',
'yakkety_newton', 'xenial_ocata', 'zesty_ocata',
'xenial_pike', 'artful_pike', 'xenial_queens',
'bionic_queens']
class OpenStackAmuletUtils(AmuletUtils): class OpenStackAmuletUtils(AmuletUtils):
"""OpenStack amulet utilities. """OpenStack amulet utilities.
@ -63,7 +70,34 @@ class OpenStackAmuletUtils(AmuletUtils):
super(OpenStackAmuletUtils, self).__init__(log_level) super(OpenStackAmuletUtils, self).__init__(log_level)
def validate_endpoint_data(self, endpoints, admin_port, internal_port, def validate_endpoint_data(self, endpoints, admin_port, internal_port,
public_port, expected): public_port, expected, openstack_release=None):
"""Validate endpoint data. Pick the correct validator based on
OpenStack release. Expected data should be in the v2 format:
{
'id': id,
'region': region,
'adminurl': adminurl,
'internalurl': internalurl,
'publicurl': publicurl,
'service_id': service_id}
"""
validation_function = self.validate_v2_endpoint_data
xenial_queens = OPENSTACK_RELEASES_PAIRS.index('xenial_queens')
if openstack_release and openstack_release >= xenial_queens:
validation_function = self.validate_v3_endpoint_data
expected = {
'id': expected['id'],
'region': expected['region'],
'region_id': 'RegionOne',
'url': self.valid_url,
'interface': self.not_null,
'service_id': expected['service_id']}
return validation_function(endpoints, admin_port, internal_port,
public_port, expected)
def validate_v2_endpoint_data(self, endpoints, admin_port, internal_port,
public_port, expected):
"""Validate endpoint data. """Validate endpoint data.
Validate actual endpoint data vs expected endpoint data. The ports Validate actual endpoint data vs expected endpoint data. The ports
@ -141,7 +175,86 @@ class OpenStackAmuletUtils(AmuletUtils):
if len(found) != expected_num_eps: if len(found) != expected_num_eps:
return 'Unexpected number of endpoints found' return 'Unexpected number of endpoints found'
def validate_svc_catalog_endpoint_data(self, expected, actual): def convert_svc_catalog_endpoint_data_to_v3(self, ep_data):
"""Convert v2 endpoint data into v3.
{
'service_name1': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
'service_name2': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
}
"""
self.log.warn("Endpoint ID and Region ID validation is limited to not "
"null checks after v2 to v3 conversion")
for svc in ep_data.keys():
assert len(ep_data[svc]) == 1, "Unknown data format"
svc_ep_data = ep_data[svc][0]
ep_data[svc] = [
{
'url': svc_ep_data['adminURL'],
'interface': 'admin',
'region': svc_ep_data['region'],
'region_id': self.not_null,
'id': self.not_null},
{
'url': svc_ep_data['publicURL'],
'interface': 'public',
'region': svc_ep_data['region'],
'region_id': self.not_null,
'id': self.not_null},
{
'url': svc_ep_data['internalURL'],
'interface': 'internal',
'region': svc_ep_data['region'],
'region_id': self.not_null,
'id': self.not_null}]
return ep_data
def validate_svc_catalog_endpoint_data(self, expected, actual,
openstack_release=None):
"""Validate service catalog endpoint data. Pick the correct validator
for the OpenStack version. Expected data should be in the v2 format:
{
'service_name1': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
'service_name2': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
}
"""
validation_function = self.validate_v2_svc_catalog_endpoint_data
xenial_queens = OPENSTACK_RELEASES_PAIRS.index('xenial_queens')
if openstack_release and openstack_release >= xenial_queens:
validation_function = self.validate_v3_svc_catalog_endpoint_data
expected = self.convert_svc_catalog_endpoint_data_to_v3(expected)
return validation_function(expected, actual)
def validate_v2_svc_catalog_endpoint_data(self, expected, actual):
"""Validate service catalog endpoint data. """Validate service catalog endpoint data.
Validate a list of actual service catalog endpoints vs a list of Validate a list of actual service catalog endpoints vs a list of
@ -328,7 +441,7 @@ class OpenStackAmuletUtils(AmuletUtils):
if rel.get('api_version') != str(api_version): if rel.get('api_version') != str(api_version):
raise Exception("api_version not propagated through relation" raise Exception("api_version not propagated through relation"
" data yet ('{}' != '{}')." " data yet ('{}' != '{}')."
"".format(rel['api_version'], api_version)) "".format(rel.get('api_version'), api_version))
def keystone_configure_api_version(self, sentry_relation_pairs, deployment, def keystone_configure_api_version(self, sentry_relation_pairs, deployment,
api_version): api_version):
@ -350,16 +463,13 @@ class OpenStackAmuletUtils(AmuletUtils):
deployment._auto_wait_for_status() deployment._auto_wait_for_status()
self.keystone_wait_for_propagation(sentry_relation_pairs, api_version) self.keystone_wait_for_propagation(sentry_relation_pairs, api_version)
def authenticate_cinder_admin(self, keystone_sentry, username, def authenticate_cinder_admin(self, keystone, api_version=2):
password, tenant, api_version=2):
"""Authenticates admin user with cinder.""" """Authenticates admin user with cinder."""
# NOTE(beisner): cinder python client doesn't accept tokens. self.log.debug('Authenticating cinder admin...')
keystone_ip = keystone_sentry.info['public-address']
ept = "http://{}:5000/v2.0".format(keystone_ip.strip().decode('utf-8'))
_clients = { _clients = {
1: cinder_client.Client, 1: cinder_client.Client,
2: cinder_clientv2.Client} 2: cinder_clientv2.Client}
return _clients[api_version](username, password, tenant, ept) return _clients[api_version](session=keystone.session)
def authenticate_keystone(self, keystone_ip, username, password, def authenticate_keystone(self, keystone_ip, username, password,
api_version=False, admin_port=False, api_version=False, admin_port=False,
@ -367,13 +477,36 @@ class OpenStackAmuletUtils(AmuletUtils):
project_domain_name=None, project_name=None): project_domain_name=None, project_name=None):
"""Authenticate with Keystone""" """Authenticate with Keystone"""
self.log.debug('Authenticating with keystone...') self.log.debug('Authenticating with keystone...')
port = 5000 if not api_version:
if admin_port: api_version = 2
port = 35357 sess, auth = self.get_keystone_session(
base_ep = "http://{}:{}".format(keystone_ip.strip().decode('utf-8'), keystone_ip=keystone_ip,
port) username=username,
if not api_version or api_version == 2: password=password,
ep = base_ep + "/v2.0" api_version=api_version,
admin_port=admin_port,
user_domain_name=user_domain_name,
domain_name=domain_name,
project_domain_name=project_domain_name,
project_name=project_name
)
if api_version == 2:
client = keystone_client.Client(session=sess)
else:
client = keystone_client_v3.Client(session=sess)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(sess)
return client
def get_keystone_session(self, keystone_ip, username, password,
api_version=False, admin_port=False,
user_domain_name=None, domain_name=None,
project_domain_name=None, project_name=None):
"""Return a keystone session object"""
ep = self.get_keystone_endpoint(keystone_ip,
api_version=api_version,
admin_port=admin_port)
if api_version == 2:
auth = v2.Password( auth = v2.Password(
username=username, username=username,
password=password, password=password,
@ -381,12 +514,7 @@ class OpenStackAmuletUtils(AmuletUtils):
auth_url=ep auth_url=ep
) )
sess = keystone_session.Session(auth=auth) sess = keystone_session.Session(auth=auth)
client = keystone_client.Client(session=sess)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(sess)
return client
else: else:
ep = base_ep + "/v3"
auth = v3.Password( auth = v3.Password(
user_domain_name=user_domain_name, user_domain_name=user_domain_name,
username=username, username=username,
@ -397,10 +525,57 @@ class OpenStackAmuletUtils(AmuletUtils):
auth_url=ep auth_url=ep
) )
sess = keystone_session.Session(auth=auth) sess = keystone_session.Session(auth=auth)
client = keystone_client_v3.Client(session=sess) return (sess, auth)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(sess) def get_keystone_endpoint(self, keystone_ip, api_version=None,
return client admin_port=False):
"""Return keystone endpoint"""
port = 5000
if admin_port:
port = 35357
base_ep = "http://{}:{}".format(keystone_ip.strip().decode('utf-8'),
port)
if api_version == 2:
ep = base_ep + "/v2.0"
else:
ep = base_ep + "/v3"
return ep
def get_default_keystone_session(self, keystone_sentry,
openstack_release=None):
"""Return a keystone session object and client object assuming standard
default settings
Example call in amulet tests:
self.keystone_session, self.keystone = u.get_default_keystone_session(
self.keystone_sentry,
openstack_release=self._get_openstack_release())
The session can then be used to auth other clients:
neutronclient.Client(session=session)
aodh_client.Client(session=session)
eyc
"""
self.log.debug('Authenticating keystone admin...')
api_version = 2
client_class = keystone_client.Client
# 11 => xenial_queens
if openstack_release and openstack_release >= 11:
api_version = 3
client_class = keystone_client_v3.Client
keystone_ip = keystone_sentry.info['public-address']
session, auth = self.get_keystone_session(
keystone_ip,
api_version=api_version,
username='admin',
password='openstack',
project_name='admin',
user_domain_name='admin_domain',
project_domain_name='admin_domain')
client = client_class(session=session)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(session)
return session, client
def authenticate_keystone_admin(self, keystone_sentry, user, password, def authenticate_keystone_admin(self, keystone_sentry, user, password,
tenant=None, api_version=None, tenant=None, api_version=None,

View File

@ -384,6 +384,7 @@ class IdentityServiceContext(OSContextGenerator):
# so a missing value just indicates keystone needs # so a missing value just indicates keystone needs
# upgrading # upgrading
ctxt['admin_tenant_id'] = rdata.get('service_tenant_id') ctxt['admin_tenant_id'] = rdata.get('service_tenant_id')
ctxt['admin_domain_id'] = rdata.get('service_domain_id')
return ctxt return ctxt
return {} return {}
@ -796,9 +797,9 @@ class ApacheSSLContext(OSContextGenerator):
key_filename = 'key' key_filename = 'key'
write_file(path=os.path.join(ssl_dir, cert_filename), write_file(path=os.path.join(ssl_dir, cert_filename),
content=b64decode(cert)) content=b64decode(cert), perms=0o640)
write_file(path=os.path.join(ssl_dir, key_filename), write_file(path=os.path.join(ssl_dir, key_filename),
content=b64decode(key)) content=b64decode(key), perms=0o640)
def configure_ca(self): def configure_ca(self):
ca_cert = get_ca_cert() ca_cert = get_ca_cert()
@ -1872,10 +1873,11 @@ class EnsureDirContext(OSContextGenerator):
context is needed to do that before rendering a template. context is needed to do that before rendering a template.
''' '''
def __init__(self, dirname): def __init__(self, dirname, **kwargs):
'''Used merely to ensure that a given directory exists.''' '''Used merely to ensure that a given directory exists.'''
self.dirname = dirname self.dirname = dirname
self.kwargs = kwargs
def __call__(self): def __call__(self):
mkdir(self.dirname) mkdir(self.dirname, **self.kwargs)
return {} return {}

View File

@ -182,7 +182,7 @@ SWIFT_CODENAMES = OrderedDict([
('pike', ('pike',
['2.13.0', '2.15.0']), ['2.13.0', '2.15.0']),
('queens', ('queens',
['2.16.0']), ['2.16.0', '2.17.0']),
]) ])
# >= Liberty version->codename mapping # >= Liberty version->codename mapping

View File

@ -0,0 +1,84 @@
# Copyright 2018 Canonical Limited.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import os
import charmhelpers.contrib.openstack.alternatives as alternatives
import charmhelpers.contrib.openstack.context as context
import charmhelpers.core.hookenv as hookenv
import charmhelpers.core.host as host
import charmhelpers.core.templating as templating
class VaultKVContext(context.OSContextGenerator):
"""Vault KV context for interaction with vault-kv interfaces"""
interfaces = ['secrets-storage']
def __init__(self, secret_backend=None):
super(context.OSContextGenerator, self).__init__()
self.secret_backend = (
secret_backend or 'charm-{}'.format(hookenv.service_name())
)
def __call__(self):
for relation_id in hookenv.relation_ids(self.interfaces[0]):
for unit in hookenv.related_units(relation_id):
vault_url = hookenv.relation_get(
'vault_url',
unit=unit,
rid=relation_id
)
role_id = hookenv.relation_get(
'{}_role_id'.format(hookenv.local_unit()),
unit=unit,
rid=relation_id
)
if vault_url and role_id:
ctxt = {
'vault_url': json.loads(vault_url),
'role_id': json.loads(role_id),
'secret_backend': self.secret_backend,
}
vault_ca = hookenv.relation_get(
'vault_ca',
unit=unit,
rid=relation_id
)
if vault_ca:
ctxt['vault_ca'] = json.loads(vault_ca)
self.complete = True
return ctxt
return {}
def write_vaultlocker_conf(context, priority=100):
"""Write vaultlocker configuration to disk and install alternative
:param context: Dict of data from vault-kv relation
:ptype: context: dict
:param priority: Priority of alternative configuration
:ptype: priority: int"""
charm_vl_path = "/var/lib/charm/{}/vaultlocker.conf".format(
hookenv.service_name()
)
host.mkdir(os.path.dirname(charm_vl_path), perms=0o700)
templating.render(source='vaultlocker.conf.j2',
target=charm_vl_path,
context=context, perms=0o600),
alternatives.install_alternative('vaultlocker.conf',
'/etc/vaultlocker/vaultlocker.conf',
charm_vl_path, priority)

View File

@ -291,7 +291,7 @@ class Pool(object):
class ReplicatedPool(Pool): class ReplicatedPool(Pool):
def __init__(self, service, name, pg_num=None, replicas=2, def __init__(self, service, name, pg_num=None, replicas=2,
percent_data=10.0): percent_data=10.0, app_name=None):
super(ReplicatedPool, self).__init__(service=service, name=name) super(ReplicatedPool, self).__init__(service=service, name=name)
self.replicas = replicas self.replicas = replicas
if pg_num: if pg_num:
@ -301,6 +301,10 @@ class ReplicatedPool(Pool):
self.pg_num = min(pg_num, max_pgs) self.pg_num = min(pg_num, max_pgs)
else: else:
self.pg_num = self.get_pgs(self.replicas, percent_data) self.pg_num = self.get_pgs(self.replicas, percent_data)
if app_name:
self.app_name = app_name
else:
self.app_name = 'unknown'
def create(self): def create(self):
if not pool_exists(self.service, self.name): if not pool_exists(self.service, self.name):
@ -313,6 +317,12 @@ class ReplicatedPool(Pool):
update_pool(client=self.service, update_pool(client=self.service,
pool=self.name, pool=self.name,
settings={'size': str(self.replicas)}) settings={'size': str(self.replicas)})
try:
set_app_name_for_pool(client=self.service,
pool=self.name,
name=self.app_name)
except CalledProcessError:
log('Could not set app name for pool {}'.format(self.name, level=WARNING))
except CalledProcessError: except CalledProcessError:
raise raise
@ -320,10 +330,14 @@ class ReplicatedPool(Pool):
# Default jerasure erasure coded pool # Default jerasure erasure coded pool
class ErasurePool(Pool): class ErasurePool(Pool):
def __init__(self, service, name, erasure_code_profile="default", def __init__(self, service, name, erasure_code_profile="default",
percent_data=10.0): percent_data=10.0, app_name=None):
super(ErasurePool, self).__init__(service=service, name=name) super(ErasurePool, self).__init__(service=service, name=name)
self.erasure_code_profile = erasure_code_profile self.erasure_code_profile = erasure_code_profile
self.percent_data = percent_data self.percent_data = percent_data
if app_name:
self.app_name = app_name
else:
self.app_name = 'unknown'
def create(self): def create(self):
if not pool_exists(self.service, self.name): if not pool_exists(self.service, self.name):
@ -355,6 +369,12 @@ class ErasurePool(Pool):
'erasure', self.erasure_code_profile] 'erasure', self.erasure_code_profile]
try: try:
check_call(cmd) check_call(cmd)
try:
set_app_name_for_pool(client=self.service,
pool=self.name,
name=self.app_name)
except CalledProcessError:
log('Could not set app name for pool {}'.format(self.name, level=WARNING))
except CalledProcessError: except CalledProcessError:
raise raise
@ -778,6 +798,25 @@ def update_pool(client, pool, settings):
check_call(cmd) check_call(cmd)
def set_app_name_for_pool(client, pool, name):
"""
Calls `osd pool application enable` for the specified pool name
:param client: Name of the ceph client to use
:type client: str
:param pool: Pool to set app name for
:type pool: str
:param name: app name for the specified pool
:type name: str
:raises: CalledProcessError if ceph call fails
"""
if ceph_version() >= '12.0.0':
cmd = ['ceph', '--id', client, 'osd', 'pool',
'application', 'enable', pool, name]
check_call(cmd)
def create_pool(service, name, replicas=3, pg_num=None): def create_pool(service, name, replicas=3, pg_num=None):
"""Create a new RADOS pool.""" """Create a new RADOS pool."""
if pool_exists(service, name): if pool_exists(service, name):

View File

@ -151,3 +151,32 @@ def extend_logical_volume_by_device(lv_name, block_device):
''' '''
cmd = ['lvextend', lv_name, block_device] cmd = ['lvextend', lv_name, block_device]
check_call(cmd) check_call(cmd)
def create_logical_volume(lv_name, volume_group, size=None):
'''
Create a new logical volume in an existing volume group
:param lv_name: str: name of logical volume to be created.
:param volume_group: str: Name of volume group to use for the new volume.
:param size: str: Size of logical volume to create (100% if not supplied)
:raises subprocess.CalledProcessError: in the event that the lvcreate fails.
'''
if size:
check_call([
'lvcreate',
'--yes',
'-L',
'{}'.format(size),
'-n', lv_name, volume_group
])
# create the lv with all the space available, this is needed because the
# system call is different for LVM
else:
check_call([
'lvcreate',
'--yes',
'-l',
'100%FREE',
'-n', lv_name, volume_group
])

View File

@ -27,6 +27,7 @@ import glob
import os import os
import json import json
import yaml import yaml
import re
import subprocess import subprocess
import sys import sys
import errno import errno
@ -67,7 +68,7 @@ def cached(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
global cache global cache
key = str((func, args, kwargs)) key = json.dumps((func, args, kwargs), sort_keys=True, default=str)
try: try:
return cache[key] return cache[key]
except KeyError: except KeyError:
@ -353,22 +354,40 @@ class Config(dict):
self.save() self.save()
@cached _cache_config = None
def config(scope=None): def config(scope=None):
"""Juju charm configuration""" """
config_cmd_line = ['config-get'] Get the juju charm configuration (scope==None) or individual key,
if scope is not None: (scope=str). The returned value is a Python data structure loaded as
config_cmd_line.append(scope) JSON from the Juju config command.
else:
config_cmd_line.append('--all') :param scope: If set, return the value for the specified key.
config_cmd_line.append('--format=json') :type scope: Optional[str]
:returns: Either the whole config as a Config, or a key from it.
:rtype: Any
"""
global _cache_config
config_cmd_line = ['config-get', '--all', '--format=json']
try: try:
config_data = json.loads( # JSON Decode Exception for Python3.5+
subprocess.check_output(config_cmd_line).decode('UTF-8')) exc_json = json.decoder.JSONDecodeError
except AttributeError:
# JSON Decode Exception for Python2.7 through Python3.4
exc_json = ValueError
try:
if _cache_config is None:
config_data = json.loads(
subprocess.check_output(config_cmd_line).decode('UTF-8'))
_cache_config = Config(config_data)
if scope is not None: if scope is not None:
return config_data return _cache_config.get(scope)
return Config(config_data) return _cache_config
except ValueError: except (exc_json, UnicodeDecodeError) as e:
log('Unable to parse output from config-get: config_cmd_line="{}" '
'message="{}"'
.format(config_cmd_line, str(e)), level=ERROR)
return None return None
@ -1043,7 +1062,6 @@ def juju_version():
universal_newlines=True).strip() universal_newlines=True).strip()
@cached
def has_juju_version(minimum_version): def has_juju_version(minimum_version):
"""Return True if the Juju version is at least the provided version""" """Return True if the Juju version is at least the provided version"""
return LooseVersion(juju_version()) >= LooseVersion(minimum_version) return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
@ -1103,6 +1121,8 @@ def _run_atexit():
@translate_exc(from_exc=OSError, to_exc=NotImplementedError) @translate_exc(from_exc=OSError, to_exc=NotImplementedError)
def network_get_primary_address(binding): def network_get_primary_address(binding):
''' '''
Deprecated since Juju 2.3; use network_get()
Retrieve the primary network address for a named binding Retrieve the primary network address for a named binding
:param binding: string. The name of a relation of extra-binding :param binding: string. The name of a relation of extra-binding
@ -1123,7 +1143,6 @@ def network_get_primary_address(binding):
return response return response
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
def network_get(endpoint, relation_id=None): def network_get(endpoint, relation_id=None):
""" """
Retrieve the network details for a relation endpoint Retrieve the network details for a relation endpoint
@ -1131,24 +1150,20 @@ def network_get(endpoint, relation_id=None):
:param endpoint: string. The name of a relation endpoint :param endpoint: string. The name of a relation endpoint
:param relation_id: int. The ID of the relation for the current context. :param relation_id: int. The ID of the relation for the current context.
:return: dict. The loaded YAML output of the network-get query. :return: dict. The loaded YAML output of the network-get query.
:raise: NotImplementedError if run on Juju < 2.1 :raise: NotImplementedError if request not supported by the Juju version.
""" """
if not has_juju_version('2.2'):
raise NotImplementedError(juju_version()) # earlier versions require --primary-address
if relation_id and not has_juju_version('2.3'):
raise NotImplementedError # 2.3 added the -r option
cmd = ['network-get', endpoint, '--format', 'yaml'] cmd = ['network-get', endpoint, '--format', 'yaml']
if relation_id: if relation_id:
cmd.append('-r') cmd.append('-r')
cmd.append(relation_id) cmd.append(relation_id)
try: response = subprocess.check_output(
response = subprocess.check_output( cmd,
cmd, stderr=subprocess.STDOUT).decode('UTF-8').strip()
stderr=subprocess.STDOUT).decode('UTF-8').strip()
except CalledProcessError as e:
# Early versions of Juju 2.0.x required the --primary-address argument.
# We catch that condition here and raise NotImplementedError since
# the requested semantics are not available - the caller can then
# use the network_get_primary_address() method instead.
if '--primary-address is currently required' in e.output.decode('UTF-8'):
raise NotImplementedError
raise
return yaml.safe_load(response) return yaml.safe_load(response)
@ -1204,9 +1219,23 @@ def iter_units_for_relation_name(relation_name):
def ingress_address(rid=None, unit=None): def ingress_address(rid=None, unit=None):
""" """
Retrieve the ingress-address from a relation when available. Otherwise, Retrieve the ingress-address from a relation when available.
return the private-address. This function is to be used on the consuming Otherwise, return the private-address.
side of the relation.
When used on the consuming side of the relation (unit is a remote
unit), the ingress-address is the IP address that this unit needs
to use to reach the provided service on the remote unit.
When used on the providing side of the relation (unit == local_unit()),
the ingress-address is the IP address that is advertised to remote
units on this relation. Remote units need to use this address to
reach the local provided service on this unit.
Note that charms may document some other method to use in
preference to the ingress_address(), such as an address provided
on a different relation attribute or a service discovery mechanism.
This allows charms to redirect inbound connections to their peers
or different applications such as load balancers.
Usage: Usage:
addresses = [ingress_address(rid=u.rid, unit=u.unit) addresses = [ingress_address(rid=u.rid, unit=u.unit)
@ -1220,3 +1249,40 @@ def ingress_address(rid=None, unit=None):
settings = relation_get(rid=rid, unit=unit) settings = relation_get(rid=rid, unit=unit)
return (settings.get('ingress-address') or return (settings.get('ingress-address') or
settings.get('private-address')) settings.get('private-address'))
def egress_subnets(rid=None, unit=None):
"""
Retrieve the egress-subnets from a relation.
This function is to be used on the providing side of the
relation, and provides the ranges of addresses that client
connections may come from. The result is uninteresting on
the consuming side of a relation (unit == local_unit()).
Returns a stable list of subnets in CIDR format.
eg. ['192.168.1.0/24', '2001::F00F/128']
If egress-subnets is not available, falls back to using the published
ingress-address, or finally private-address.
:param rid: string relation id
:param unit: string unit name
:side effect: calls relation_get
:return: list of subnets in CIDR format. eg. ['192.168.1.0/24', '2001::F00F/128']
"""
def _to_range(addr):
if re.search(r'^(?:\d{1,3}\.){3}\d{1,3}$', addr) is not None:
addr += '/32'
elif ':' in addr and '/' not in addr: # IPv6
addr += '/128'
return addr
settings = relation_get(rid=rid, unit=unit)
if 'egress-subnets' in settings:
return [n.strip() for n in settings['egress-subnets'].split(',') if n.strip()]
if 'ingress-address' in settings:
return [_to_range(settings['ingress-address'])]
if 'private-address' in settings:
return [_to_range(settings['private-address'])]
return [] # Should never happen

View File

@ -993,7 +993,7 @@ def updatedb(updatedb_text, new_path):
return output return output
def modulo_distribution(modulo=3, wait=30): def modulo_distribution(modulo=3, wait=30, non_zero_wait=False):
""" Modulo distribution """ Modulo distribution
This helper uses the unit number, a modulo value and a constant wait time This helper uses the unit number, a modulo value and a constant wait time
@ -1015,7 +1015,14 @@ def modulo_distribution(modulo=3, wait=30):
@param modulo: int The modulo number creates the group distribution @param modulo: int The modulo number creates the group distribution
@param wait: int The constant time wait value @param wait: int The constant time wait value
@param non_zero_wait: boolean Override unit % modulo == 0,
return modulo * wait. Used to avoid collisions with
leader nodes which are often given priority.
@return: int Calculated time to wait for unit operation @return: int Calculated time to wait for unit operation
""" """
unit_number = int(local_unit().split('/')[1]) unit_number = int(local_unit().split('/')[1])
return (unit_number % modulo) * wait calculated_wait_time = (unit_number % modulo) * wait
if non_zero_wait and calculated_wait_time == 0:
return modulo * wait
else:
return calculated_wait_time

View File

@ -307,23 +307,34 @@ class PortManagerCallback(ManagerCallback):
""" """
def __call__(self, manager, service_name, event_name): def __call__(self, manager, service_name, event_name):
service = manager.get_service(service_name) service = manager.get_service(service_name)
new_ports = service.get('ports', []) # turn this generator into a list,
# as we'll be going over it multiple times
new_ports = list(service.get('ports', []))
port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name)) port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
if os.path.exists(port_file): if os.path.exists(port_file):
with open(port_file) as fp: with open(port_file) as fp:
old_ports = fp.read().split(',') old_ports = fp.read().split(',')
for old_port in old_ports: for old_port in old_ports:
if bool(old_port): if bool(old_port) and not self.ports_contains(old_port, new_ports):
old_port = int(old_port) hookenv.close_port(old_port)
if old_port not in new_ports:
hookenv.close_port(old_port)
with open(port_file, 'w') as fp: with open(port_file, 'w') as fp:
fp.write(','.join(str(port) for port in new_ports)) fp.write(','.join(str(port) for port in new_ports))
for port in new_ports: for port in new_ports:
# A port is either a number or 'ICMP'
protocol = 'TCP'
if str(port).upper() == 'ICMP':
protocol = 'ICMP'
if event_name == 'start': if event_name == 'start':
hookenv.open_port(port) hookenv.open_port(port, protocol)
elif event_name == 'stop': elif event_name == 'stop':
hookenv.close_port(port) hookenv.close_port(port, protocol)
def ports_contains(self, port, ports):
if not bool(port):
return False
if str(port).upper() != 'ICMP':
port = int(port)
return port in ports
def service_stop(service_name): def service_stop(service_name):

View File

@ -166,6 +166,10 @@ class Storage(object):
To support dicts, lists, integer, floats, and booleans values To support dicts, lists, integer, floats, and booleans values
are automatically json encoded/decoded. are automatically json encoded/decoded.
Note: to facilitate unit testing, ':memory:' can be passed as the
path parameter which causes sqlite3 to only build the db in memory.
This should only be used for testing purposes.
""" """
def __init__(self, path=None): def __init__(self, path=None):
self.db_path = path self.db_path = path
@ -175,8 +179,9 @@ class Storage(object):
else: else:
self.db_path = os.path.join( self.db_path = os.path.join(
os.environ.get('CHARM_DIR', ''), '.unit-state.db') os.environ.get('CHARM_DIR', ''), '.unit-state.db')
with open(self.db_path, 'a') as f: if self.db_path != ':memory:':
os.fchmod(f.fileno(), 0o600) with open(self.db_path, 'a') as f:
os.fchmod(f.fileno(), 0o600)
self.conn = sqlite3.connect('%s' % self.db_path) self.conn = sqlite3.connect('%s' % self.db_path)
self.cursor = self.conn.cursor() self.cursor = self.conn.cursor()
self.revision = None self.revision = None

View File

@ -44,6 +44,7 @@ ARCH_TO_PROPOSED_POCKET = {
'x86_64': PROPOSED_POCKET, 'x86_64': PROPOSED_POCKET,
'ppc64le': PROPOSED_PORTS_POCKET, 'ppc64le': PROPOSED_PORTS_POCKET,
'aarch64': PROPOSED_PORTS_POCKET, 'aarch64': PROPOSED_PORTS_POCKET,
's390x': PROPOSED_PORTS_POCKET,
} }
CLOUD_ARCHIVE_URL = "http://ubuntu-cloud.archive.canonical.com/ubuntu" CLOUD_ARCHIVE_URL = "http://ubuntu-cloud.archive.canonical.com/ubuntu"
CLOUD_ARCHIVE_KEY_ID = '5EDB1B62EC4926EA' CLOUD_ARCHIVE_KEY_ID = '5EDB1B62EC4926EA'

View File

@ -370,6 +370,8 @@ def handle_erasure_pool(request, service):
if erasure_profile is None: if erasure_profile is None:
erasure_profile = "default-canonical" erasure_profile = "default-canonical"
app_name = request.get('app-name')
# Check for missing params # Check for missing params
if pool_name is None: if pool_name is None:
msg = "Missing parameter. name is required for the pool" msg = "Missing parameter. name is required for the pool"
@ -393,7 +395,7 @@ def handle_erasure_pool(request, service):
pool = ErasurePool(service=service, name=pool_name, pool = ErasurePool(service=service, name=pool_name,
erasure_code_profile=erasure_profile, erasure_code_profile=erasure_profile,
percent_data=weight) percent_data=weight, app_name=app_name)
# Ok make the erasure pool # Ok make the erasure pool
if not pool_exists(service=service, name=pool_name): if not pool_exists(service=service, name=pool_name):
log("Creating pool '{}' (erasure_profile={})" log("Creating pool '{}' (erasure_profile={})"
@ -426,6 +428,7 @@ def handle_replicated_pool(request, service):
if osds: if osds:
pg_num = min(pg_num, (len(osds) * 100 // replicas)) pg_num = min(pg_num, (len(osds) * 100 // replicas))
app_name = request.get('app-name')
# Check for missing params # Check for missing params
if pool_name is None or replicas is None: if pool_name is None or replicas is None:
msg = "Missing parameter. name and replicas are required" msg = "Missing parameter. name and replicas are required"
@ -446,6 +449,8 @@ def handle_replicated_pool(request, service):
kwargs['percent_data'] = weight kwargs['percent_data'] = weight
if replicas: if replicas:
kwargs['replicas'] = replicas kwargs['replicas'] = replicas
if app_name:
kwargs['app_name'] = app_name
pool = ReplicatedPool(service=service, pool = ReplicatedPool(service=service,
name=pool_name, **kwargs) name=pool_name, **kwargs)

View File

@ -155,10 +155,7 @@ class CephBasicDeployment(OpenStackAmuletDeployment):
password='openstack', password='openstack',
tenant='admin') tenant='admin')
# Authenticate admin with cinder endpoint # Authenticate admin with cinder endpoint
self.cinder = u.authenticate_cinder_admin(self.keystone_sentry, self.cinder = u.authenticate_cinder_admin(self.keystone)
username='admin',
password='openstack',
tenant='admin')
# Authenticate admin with glance endpoint # Authenticate admin with glance endpoint
self.glance = u.authenticate_glance_admin(self.keystone) self.glance = u.authenticate_glance_admin(self.keystone)

View File

@ -21,6 +21,9 @@ from collections import OrderedDict
from charmhelpers.contrib.amulet.deployment import ( from charmhelpers.contrib.amulet.deployment import (
AmuletDeployment AmuletDeployment
) )
from charmhelpers.contrib.openstack.amulet.utils import (
OPENSTACK_RELEASES_PAIRS
)
DEBUG = logging.DEBUG DEBUG = logging.DEBUG
ERROR = logging.ERROR ERROR = logging.ERROR
@ -271,11 +274,8 @@ class OpenStackAmuletDeployment(AmuletDeployment):
release. release.
""" """
# Must be ordered by OpenStack release (not by Ubuntu release): # Must be ordered by OpenStack release (not by Ubuntu release):
(self.trusty_icehouse, self.trusty_kilo, self.trusty_liberty, for i, os_pair in enumerate(OPENSTACK_RELEASES_PAIRS):
self.trusty_mitaka, self.xenial_mitaka, self.xenial_newton, setattr(self, os_pair, i)
self.yakkety_newton, self.xenial_ocata, self.zesty_ocata,
self.xenial_pike, self.artful_pike, self.xenial_queens,
self.bionic_queens,) = range(13)
releases = { releases = {
('trusty', None): self.trusty_icehouse, ('trusty', None): self.trusty_icehouse,

View File

@ -50,6 +50,13 @@ ERROR = logging.ERROR
NOVA_CLIENT_VERSION = "2" NOVA_CLIENT_VERSION = "2"
OPENSTACK_RELEASES_PAIRS = [
'trusty_icehouse', 'trusty_kilo', 'trusty_liberty',
'trusty_mitaka', 'xenial_mitaka', 'xenial_newton',
'yakkety_newton', 'xenial_ocata', 'zesty_ocata',
'xenial_pike', 'artful_pike', 'xenial_queens',
'bionic_queens']
class OpenStackAmuletUtils(AmuletUtils): class OpenStackAmuletUtils(AmuletUtils):
"""OpenStack amulet utilities. """OpenStack amulet utilities.
@ -63,7 +70,34 @@ class OpenStackAmuletUtils(AmuletUtils):
super(OpenStackAmuletUtils, self).__init__(log_level) super(OpenStackAmuletUtils, self).__init__(log_level)
def validate_endpoint_data(self, endpoints, admin_port, internal_port, def validate_endpoint_data(self, endpoints, admin_port, internal_port,
public_port, expected): public_port, expected, openstack_release=None):
"""Validate endpoint data. Pick the correct validator based on
OpenStack release. Expected data should be in the v2 format:
{
'id': id,
'region': region,
'adminurl': adminurl,
'internalurl': internalurl,
'publicurl': publicurl,
'service_id': service_id}
"""
validation_function = self.validate_v2_endpoint_data
xenial_queens = OPENSTACK_RELEASES_PAIRS.index('xenial_queens')
if openstack_release and openstack_release >= xenial_queens:
validation_function = self.validate_v3_endpoint_data
expected = {
'id': expected['id'],
'region': expected['region'],
'region_id': 'RegionOne',
'url': self.valid_url,
'interface': self.not_null,
'service_id': expected['service_id']}
return validation_function(endpoints, admin_port, internal_port,
public_port, expected)
def validate_v2_endpoint_data(self, endpoints, admin_port, internal_port,
public_port, expected):
"""Validate endpoint data. """Validate endpoint data.
Validate actual endpoint data vs expected endpoint data. The ports Validate actual endpoint data vs expected endpoint data. The ports
@ -141,7 +175,86 @@ class OpenStackAmuletUtils(AmuletUtils):
if len(found) != expected_num_eps: if len(found) != expected_num_eps:
return 'Unexpected number of endpoints found' return 'Unexpected number of endpoints found'
def validate_svc_catalog_endpoint_data(self, expected, actual): def convert_svc_catalog_endpoint_data_to_v3(self, ep_data):
"""Convert v2 endpoint data into v3.
{
'service_name1': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
'service_name2': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
}
"""
self.log.warn("Endpoint ID and Region ID validation is limited to not "
"null checks after v2 to v3 conversion")
for svc in ep_data.keys():
assert len(ep_data[svc]) == 1, "Unknown data format"
svc_ep_data = ep_data[svc][0]
ep_data[svc] = [
{
'url': svc_ep_data['adminURL'],
'interface': 'admin',
'region': svc_ep_data['region'],
'region_id': self.not_null,
'id': self.not_null},
{
'url': svc_ep_data['publicURL'],
'interface': 'public',
'region': svc_ep_data['region'],
'region_id': self.not_null,
'id': self.not_null},
{
'url': svc_ep_data['internalURL'],
'interface': 'internal',
'region': svc_ep_data['region'],
'region_id': self.not_null,
'id': self.not_null}]
return ep_data
def validate_svc_catalog_endpoint_data(self, expected, actual,
openstack_release=None):
"""Validate service catalog endpoint data. Pick the correct validator
for the OpenStack version. Expected data should be in the v2 format:
{
'service_name1': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
'service_name2': [
{
'adminURL': adminURL,
'id': id,
'region': region.
'publicURL': publicURL,
'internalURL': internalURL
}],
}
"""
validation_function = self.validate_v2_svc_catalog_endpoint_data
xenial_queens = OPENSTACK_RELEASES_PAIRS.index('xenial_queens')
if openstack_release and openstack_release >= xenial_queens:
validation_function = self.validate_v3_svc_catalog_endpoint_data
expected = self.convert_svc_catalog_endpoint_data_to_v3(expected)
return validation_function(expected, actual)
def validate_v2_svc_catalog_endpoint_data(self, expected, actual):
"""Validate service catalog endpoint data. """Validate service catalog endpoint data.
Validate a list of actual service catalog endpoints vs a list of Validate a list of actual service catalog endpoints vs a list of
@ -328,7 +441,7 @@ class OpenStackAmuletUtils(AmuletUtils):
if rel.get('api_version') != str(api_version): if rel.get('api_version') != str(api_version):
raise Exception("api_version not propagated through relation" raise Exception("api_version not propagated through relation"
" data yet ('{}' != '{}')." " data yet ('{}' != '{}')."
"".format(rel['api_version'], api_version)) "".format(rel.get('api_version'), api_version))
def keystone_configure_api_version(self, sentry_relation_pairs, deployment, def keystone_configure_api_version(self, sentry_relation_pairs, deployment,
api_version): api_version):
@ -350,16 +463,13 @@ class OpenStackAmuletUtils(AmuletUtils):
deployment._auto_wait_for_status() deployment._auto_wait_for_status()
self.keystone_wait_for_propagation(sentry_relation_pairs, api_version) self.keystone_wait_for_propagation(sentry_relation_pairs, api_version)
def authenticate_cinder_admin(self, keystone_sentry, username, def authenticate_cinder_admin(self, keystone, api_version=2):
password, tenant, api_version=2):
"""Authenticates admin user with cinder.""" """Authenticates admin user with cinder."""
# NOTE(beisner): cinder python client doesn't accept tokens. self.log.debug('Authenticating cinder admin...')
keystone_ip = keystone_sentry.info['public-address']
ept = "http://{}:5000/v2.0".format(keystone_ip.strip().decode('utf-8'))
_clients = { _clients = {
1: cinder_client.Client, 1: cinder_client.Client,
2: cinder_clientv2.Client} 2: cinder_clientv2.Client}
return _clients[api_version](username, password, tenant, ept) return _clients[api_version](session=keystone.session)
def authenticate_keystone(self, keystone_ip, username, password, def authenticate_keystone(self, keystone_ip, username, password,
api_version=False, admin_port=False, api_version=False, admin_port=False,
@ -367,13 +477,36 @@ class OpenStackAmuletUtils(AmuletUtils):
project_domain_name=None, project_name=None): project_domain_name=None, project_name=None):
"""Authenticate with Keystone""" """Authenticate with Keystone"""
self.log.debug('Authenticating with keystone...') self.log.debug('Authenticating with keystone...')
port = 5000 if not api_version:
if admin_port: api_version = 2
port = 35357 sess, auth = self.get_keystone_session(
base_ep = "http://{}:{}".format(keystone_ip.strip().decode('utf-8'), keystone_ip=keystone_ip,
port) username=username,
if not api_version or api_version == 2: password=password,
ep = base_ep + "/v2.0" api_version=api_version,
admin_port=admin_port,
user_domain_name=user_domain_name,
domain_name=domain_name,
project_domain_name=project_domain_name,
project_name=project_name
)
if api_version == 2:
client = keystone_client.Client(session=sess)
else:
client = keystone_client_v3.Client(session=sess)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(sess)
return client
def get_keystone_session(self, keystone_ip, username, password,
api_version=False, admin_port=False,
user_domain_name=None, domain_name=None,
project_domain_name=None, project_name=None):
"""Return a keystone session object"""
ep = self.get_keystone_endpoint(keystone_ip,
api_version=api_version,
admin_port=admin_port)
if api_version == 2:
auth = v2.Password( auth = v2.Password(
username=username, username=username,
password=password, password=password,
@ -381,12 +514,7 @@ class OpenStackAmuletUtils(AmuletUtils):
auth_url=ep auth_url=ep
) )
sess = keystone_session.Session(auth=auth) sess = keystone_session.Session(auth=auth)
client = keystone_client.Client(session=sess)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(sess)
return client
else: else:
ep = base_ep + "/v3"
auth = v3.Password( auth = v3.Password(
user_domain_name=user_domain_name, user_domain_name=user_domain_name,
username=username, username=username,
@ -397,10 +525,57 @@ class OpenStackAmuletUtils(AmuletUtils):
auth_url=ep auth_url=ep
) )
sess = keystone_session.Session(auth=auth) sess = keystone_session.Session(auth=auth)
client = keystone_client_v3.Client(session=sess) return (sess, auth)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(sess) def get_keystone_endpoint(self, keystone_ip, api_version=None,
return client admin_port=False):
"""Return keystone endpoint"""
port = 5000
if admin_port:
port = 35357
base_ep = "http://{}:{}".format(keystone_ip.strip().decode('utf-8'),
port)
if api_version == 2:
ep = base_ep + "/v2.0"
else:
ep = base_ep + "/v3"
return ep
def get_default_keystone_session(self, keystone_sentry,
openstack_release=None):
"""Return a keystone session object and client object assuming standard
default settings
Example call in amulet tests:
self.keystone_session, self.keystone = u.get_default_keystone_session(
self.keystone_sentry,
openstack_release=self._get_openstack_release())
The session can then be used to auth other clients:
neutronclient.Client(session=session)
aodh_client.Client(session=session)
eyc
"""
self.log.debug('Authenticating keystone admin...')
api_version = 2
client_class = keystone_client.Client
# 11 => xenial_queens
if openstack_release and openstack_release >= 11:
api_version = 3
client_class = keystone_client_v3.Client
keystone_ip = keystone_sentry.info['public-address']
session, auth = self.get_keystone_session(
keystone_ip,
api_version=api_version,
username='admin',
password='openstack',
project_name='admin',
user_domain_name='admin_domain',
project_domain_name='admin_domain')
client = client_class(session=session)
# This populates the client.service_catalog
client.auth_ref = auth.get_access(session)
return session, client
def authenticate_keystone_admin(self, keystone_sentry, user, password, def authenticate_keystone_admin(self, keystone_sentry, user, password,
tenant=None, api_version=None, tenant=None, api_version=None,

View File

@ -27,6 +27,7 @@ import glob
import os import os
import json import json
import yaml import yaml
import re
import subprocess import subprocess
import sys import sys
import errno import errno
@ -67,7 +68,7 @@ def cached(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
global cache global cache
key = str((func, args, kwargs)) key = json.dumps((func, args, kwargs), sort_keys=True, default=str)
try: try:
return cache[key] return cache[key]
except KeyError: except KeyError:
@ -353,22 +354,40 @@ class Config(dict):
self.save() self.save()
@cached _cache_config = None
def config(scope=None): def config(scope=None):
"""Juju charm configuration""" """
config_cmd_line = ['config-get'] Get the juju charm configuration (scope==None) or individual key,
if scope is not None: (scope=str). The returned value is a Python data structure loaded as
config_cmd_line.append(scope) JSON from the Juju config command.
else:
config_cmd_line.append('--all') :param scope: If set, return the value for the specified key.
config_cmd_line.append('--format=json') :type scope: Optional[str]
:returns: Either the whole config as a Config, or a key from it.
:rtype: Any
"""
global _cache_config
config_cmd_line = ['config-get', '--all', '--format=json']
try: try:
config_data = json.loads( # JSON Decode Exception for Python3.5+
subprocess.check_output(config_cmd_line).decode('UTF-8')) exc_json = json.decoder.JSONDecodeError
except AttributeError:
# JSON Decode Exception for Python2.7 through Python3.4
exc_json = ValueError
try:
if _cache_config is None:
config_data = json.loads(
subprocess.check_output(config_cmd_line).decode('UTF-8'))
_cache_config = Config(config_data)
if scope is not None: if scope is not None:
return config_data return _cache_config.get(scope)
return Config(config_data) return _cache_config
except ValueError: except (exc_json, UnicodeDecodeError) as e:
log('Unable to parse output from config-get: config_cmd_line="{}" '
'message="{}"'
.format(config_cmd_line, str(e)), level=ERROR)
return None return None
@ -1043,7 +1062,6 @@ def juju_version():
universal_newlines=True).strip() universal_newlines=True).strip()
@cached
def has_juju_version(minimum_version): def has_juju_version(minimum_version):
"""Return True if the Juju version is at least the provided version""" """Return True if the Juju version is at least the provided version"""
return LooseVersion(juju_version()) >= LooseVersion(minimum_version) return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
@ -1103,6 +1121,8 @@ def _run_atexit():
@translate_exc(from_exc=OSError, to_exc=NotImplementedError) @translate_exc(from_exc=OSError, to_exc=NotImplementedError)
def network_get_primary_address(binding): def network_get_primary_address(binding):
''' '''
Deprecated since Juju 2.3; use network_get()
Retrieve the primary network address for a named binding Retrieve the primary network address for a named binding
:param binding: string. The name of a relation of extra-binding :param binding: string. The name of a relation of extra-binding
@ -1123,7 +1143,6 @@ def network_get_primary_address(binding):
return response return response
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
def network_get(endpoint, relation_id=None): def network_get(endpoint, relation_id=None):
""" """
Retrieve the network details for a relation endpoint Retrieve the network details for a relation endpoint
@ -1131,24 +1150,20 @@ def network_get(endpoint, relation_id=None):
:param endpoint: string. The name of a relation endpoint :param endpoint: string. The name of a relation endpoint
:param relation_id: int. The ID of the relation for the current context. :param relation_id: int. The ID of the relation for the current context.
:return: dict. The loaded YAML output of the network-get query. :return: dict. The loaded YAML output of the network-get query.
:raise: NotImplementedError if run on Juju < 2.1 :raise: NotImplementedError if request not supported by the Juju version.
""" """
if not has_juju_version('2.2'):
raise NotImplementedError(juju_version()) # earlier versions require --primary-address
if relation_id and not has_juju_version('2.3'):
raise NotImplementedError # 2.3 added the -r option
cmd = ['network-get', endpoint, '--format', 'yaml'] cmd = ['network-get', endpoint, '--format', 'yaml']
if relation_id: if relation_id:
cmd.append('-r') cmd.append('-r')
cmd.append(relation_id) cmd.append(relation_id)
try: response = subprocess.check_output(
response = subprocess.check_output( cmd,
cmd, stderr=subprocess.STDOUT).decode('UTF-8').strip()
stderr=subprocess.STDOUT).decode('UTF-8').strip()
except CalledProcessError as e:
# Early versions of Juju 2.0.x required the --primary-address argument.
# We catch that condition here and raise NotImplementedError since
# the requested semantics are not available - the caller can then
# use the network_get_primary_address() method instead.
if '--primary-address is currently required' in e.output.decode('UTF-8'):
raise NotImplementedError
raise
return yaml.safe_load(response) return yaml.safe_load(response)
@ -1204,9 +1219,23 @@ def iter_units_for_relation_name(relation_name):
def ingress_address(rid=None, unit=None): def ingress_address(rid=None, unit=None):
""" """
Retrieve the ingress-address from a relation when available. Otherwise, Retrieve the ingress-address from a relation when available.
return the private-address. This function is to be used on the consuming Otherwise, return the private-address.
side of the relation.
When used on the consuming side of the relation (unit is a remote
unit), the ingress-address is the IP address that this unit needs
to use to reach the provided service on the remote unit.
When used on the providing side of the relation (unit == local_unit()),
the ingress-address is the IP address that is advertised to remote
units on this relation. Remote units need to use this address to
reach the local provided service on this unit.
Note that charms may document some other method to use in
preference to the ingress_address(), such as an address provided
on a different relation attribute or a service discovery mechanism.
This allows charms to redirect inbound connections to their peers
or different applications such as load balancers.
Usage: Usage:
addresses = [ingress_address(rid=u.rid, unit=u.unit) addresses = [ingress_address(rid=u.rid, unit=u.unit)
@ -1220,3 +1249,40 @@ def ingress_address(rid=None, unit=None):
settings = relation_get(rid=rid, unit=unit) settings = relation_get(rid=rid, unit=unit)
return (settings.get('ingress-address') or return (settings.get('ingress-address') or
settings.get('private-address')) settings.get('private-address'))
def egress_subnets(rid=None, unit=None):
"""
Retrieve the egress-subnets from a relation.
This function is to be used on the providing side of the
relation, and provides the ranges of addresses that client
connections may come from. The result is uninteresting on
the consuming side of a relation (unit == local_unit()).
Returns a stable list of subnets in CIDR format.
eg. ['192.168.1.0/24', '2001::F00F/128']
If egress-subnets is not available, falls back to using the published
ingress-address, or finally private-address.
:param rid: string relation id
:param unit: string unit name
:side effect: calls relation_get
:return: list of subnets in CIDR format. eg. ['192.168.1.0/24', '2001::F00F/128']
"""
def _to_range(addr):
if re.search(r'^(?:\d{1,3}\.){3}\d{1,3}$', addr) is not None:
addr += '/32'
elif ':' in addr and '/' not in addr: # IPv6
addr += '/128'
return addr
settings = relation_get(rid=rid, unit=unit)
if 'egress-subnets' in settings:
return [n.strip() for n in settings['egress-subnets'].split(',') if n.strip()]
if 'ingress-address' in settings:
return [_to_range(settings['ingress-address'])]
if 'private-address' in settings:
return [_to_range(settings['private-address'])]
return [] # Should never happen

View File

@ -993,7 +993,7 @@ def updatedb(updatedb_text, new_path):
return output return output
def modulo_distribution(modulo=3, wait=30): def modulo_distribution(modulo=3, wait=30, non_zero_wait=False):
""" Modulo distribution """ Modulo distribution
This helper uses the unit number, a modulo value and a constant wait time This helper uses the unit number, a modulo value and a constant wait time
@ -1015,7 +1015,14 @@ def modulo_distribution(modulo=3, wait=30):
@param modulo: int The modulo number creates the group distribution @param modulo: int The modulo number creates the group distribution
@param wait: int The constant time wait value @param wait: int The constant time wait value
@param non_zero_wait: boolean Override unit % modulo == 0,
return modulo * wait. Used to avoid collisions with
leader nodes which are often given priority.
@return: int Calculated time to wait for unit operation @return: int Calculated time to wait for unit operation
""" """
unit_number = int(local_unit().split('/')[1]) unit_number = int(local_unit().split('/')[1])
return (unit_number % modulo) * wait calculated_wait_time = (unit_number % modulo) * wait
if non_zero_wait and calculated_wait_time == 0:
return modulo * wait
else:
return calculated_wait_time

View File

@ -307,23 +307,34 @@ class PortManagerCallback(ManagerCallback):
""" """
def __call__(self, manager, service_name, event_name): def __call__(self, manager, service_name, event_name):
service = manager.get_service(service_name) service = manager.get_service(service_name)
new_ports = service.get('ports', []) # turn this generator into a list,
# as we'll be going over it multiple times
new_ports = list(service.get('ports', []))
port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name)) port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
if os.path.exists(port_file): if os.path.exists(port_file):
with open(port_file) as fp: with open(port_file) as fp:
old_ports = fp.read().split(',') old_ports = fp.read().split(',')
for old_port in old_ports: for old_port in old_ports:
if bool(old_port): if bool(old_port) and not self.ports_contains(old_port, new_ports):
old_port = int(old_port) hookenv.close_port(old_port)
if old_port not in new_ports:
hookenv.close_port(old_port)
with open(port_file, 'w') as fp: with open(port_file, 'w') as fp:
fp.write(','.join(str(port) for port in new_ports)) fp.write(','.join(str(port) for port in new_ports))
for port in new_ports: for port in new_ports:
# A port is either a number or 'ICMP'
protocol = 'TCP'
if str(port).upper() == 'ICMP':
protocol = 'ICMP'
if event_name == 'start': if event_name == 'start':
hookenv.open_port(port) hookenv.open_port(port, protocol)
elif event_name == 'stop': elif event_name == 'stop':
hookenv.close_port(port) hookenv.close_port(port, protocol)
def ports_contains(self, port, ports):
if not bool(port):
return False
if str(port).upper() != 'ICMP':
port = int(port)
return port in ports
def service_stop(service_name): def service_stop(service_name):

View File

@ -166,6 +166,10 @@ class Storage(object):
To support dicts, lists, integer, floats, and booleans values To support dicts, lists, integer, floats, and booleans values
are automatically json encoded/decoded. are automatically json encoded/decoded.
Note: to facilitate unit testing, ':memory:' can be passed as the
path parameter which causes sqlite3 to only build the db in memory.
This should only be used for testing purposes.
""" """
def __init__(self, path=None): def __init__(self, path=None):
self.db_path = path self.db_path = path
@ -175,8 +179,9 @@ class Storage(object):
else: else:
self.db_path = os.path.join( self.db_path = os.path.join(
os.environ.get('CHARM_DIR', ''), '.unit-state.db') os.environ.get('CHARM_DIR', ''), '.unit-state.db')
with open(self.db_path, 'a') as f: if self.db_path != ':memory:':
os.fchmod(f.fileno(), 0o600) with open(self.db_path, 'a') as f:
os.fchmod(f.fileno(), 0o600)
self.conn = sqlite3.connect('%s' % self.db_path) self.conn = sqlite3.connect('%s' % self.db_path)
self.cursor = self.conn.cursor() self.cursor = self.conn.cursor()
self.revision = None self.revision = None