Initial commit

This commit is contained in:
Liam Young 2021-09-27 11:17:05 +01:00
commit 376a29181a
19 changed files with 1680 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
venv/
build/
.idea/
*.charm
.tox
venv
.coverage
__pycache__/
*.py[cod]
**.swp
.stestr/
lib/charms/nginx*

3
.stestr.conf Normal file
View File

@ -0,0 +1,3 @@
[DEFAULT]
test_path=./unit_tests
top_dir=./

View File

View File

@ -0,0 +1,112 @@
import ops_openstack.adapters
class ConfigAdapter():
def __init__(self, charm, namespace):
self.charm = charm
self.namespace = namespace
for k, v in self.context().items():
k = k.replace('-', '_')
setattr(self, k, v)
class CharmConfigAdapter(ConfigAdapter):
def context(self):
return self.charm.config
class WSGIWorkerConfigAdapter(ConfigAdapter):
def context(self):
return {
'name': self.charm.service_name,
'wsgi_admin_script': self.charm.wsgi_admin_script,
'wsgi_public_script': self.charm.wsgi_public_script}
class DBAdapter(ops_openstack.adapters.OpenStackOperRelationAdapter):
@property
def database(self):
return self.relation.databases()[0]
@property
def database_host(self):
return self.relation.credentials().get('address')
@property
def database_password(self):
return self.relation.credentials().get('password')
@property
def database_user(self):
return self.relation.credentials().get('username')
@property
def database_type(self):
return 'mysql+pymysql'
class OPSRelationAdapters():
def __init__(self, charm):
self.charm = charm
self.namespaces = []
def _get_adapter(self, relation_name):
# Matching relation first
# Then interface name
if self.relation_map.get(relation_name):
return self.relation_map.get(relation_name)
interface_name = self.charm.meta.relations[
relation_name].interface_name
if self.interface_map.get(interface_name):
return self.interface_map.get(interface_name)
def add_relation_adapter(self, interface, relation_name):
adapter = self._get_adapter(relation_name)
if adapter:
adapter_ns = relation_name.replace("-", "_")
self.namespaces.append(adapter_ns)
setattr(self, adapter_ns, adapter(interface))
else:
logging.debug(f"No adapter found for {relation_name}")
def add_config_adapters(self, config_adapters):
for config_adapter in config_adapters:
self.add_config_adapter(
config_adapter,
config_adapter.namespace)
def add_config_adapter(self, config_adapter, namespace):
self.namespaces.append(namespace)
setattr(self, namespace, config_adapter)
@property
def interface_map(self):
return {}
@property
def relation_map(self):
return {}
def __iter__(self):
"""
Iterate over the relations presented to the charm.
"""
for namespace in self.namespaces:
yield namespace, getattr(self, namespace)
class APICharmAdapters(OPSRelationAdapters):
"""Collection of relation adapters."""
@property
def interface_map(self):
_map = super().interface_map
_map.update({
'mysql_datastore': DBAdapter})
return _map

View File

@ -0,0 +1,335 @@
#!/usr/bin/env python3
# Copyright 2021 Billy Olsen
# See LICENSE file for licensing details.
#
# Learn more at: https://juju.is/docs/sdk
"""Charm the service.
Refer to the following post for a quick-start guide that will help you
develop a new k8s charm using the Operator Framework:
https://discourse.charmhub.io/t/4208
"""
import collections
import logging
import ops_openstack
import ops_openstack.adapters
from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
from charms.mysql.v1.mysql import MySQLConsumer
from ops.charm import CharmBase
from ops.charm import PebbleReadyEvent
from ops import model
from ops.framework import StoredState
import advanced_sunbeam_openstack.adapters as sunbeam_adapters
from advanced_sunbeam_openstack.templating import sidecar_config_render
import advanced_sunbeam_openstack.cprocess as sunbeam_cprocess
logger = logging.getLogger(__name__)
ContainerConfigFile = collections.namedtuple(
'ContainerConfigFile',
['container_names', 'path', 'user', 'group'])
class OSBaseOperatorCharm(CharmBase):
_state = StoredState()
def __init__(self, framework, adapters=None):
if adapters:
self.adapters = adapters
else:
self.adapters = sunbeam_adapters.OPSRelationAdapters(self)
super().__init__(framework)
self.adapters.add_config_adapters(self.config_adapters)
self.framework.observe(self.on.config_changed,
self._on_config_changed)
self.container_configs = []
self.handlers = self.setup_event_handlers()
@property
def config_adapters(self):
return [
sunbeam_adapters.CharmConfigAdapter(self, 'options')]
@property
def handler_prefix(self):
return self.service_name.replace('-', '_')
@property
def container_names(self):
return [self.service_name]
@property
def template_dir(self):
return 'src/templates'
def renderer(self, containers, container_configs, template_dir,
openstack_release, adapters):
sidecar_config_render(
containers,
self.container_configs,
self.template_dir,
self.openstack_release,
self.adapters)
def write_config(self):
containers = [self.unit.get_container(c_name)
for c_name in self.container_names]
if all(containers):
self.renderer(
containers,
self.container_configs,
self.template_dir,
self.openstack_release,
self.adapters)
else:
logger.debug(
'One or more containers are not ready')
def setup_event_handlers(self):
self.setup_pebble_handler()
return []
def setup_pebble_handler(self):
pebble_ready_event = getattr(
self.on,
f'{self.handler_prefix}_pebble_ready')
self.framework.observe(pebble_ready_event,
self._on_service_pebble_ready)
def _on_service_pebble_ready(self, event: PebbleReadyEvent) -> None:
raise NotImplementedError
def _on_config_changed(self, event):
raise NotImplementedError
class OSBaseOperatorAPICharm(OSBaseOperatorCharm):
_state = StoredState()
def __init__(self, framework, adapters=None):
if not adapters:
adapters = sunbeam_adapters.APICharmAdapters(self)
super().__init__(framework, adapters)
self._state.set_default(db_ready=False)
self._state.set_default(bootstrapped=False)
self.container_configs = [
ContainerConfigFile(
[self.wsgi_container_name],
self.wsgi_conf,
'root',
'root')]
self.write_config()
@property
def config_adapters(self):
_cadapters = super().config_adapters
_cadapters.extend([
sunbeam_adapters.WSGIWorkerConfigAdapter(self, 'wsgi_config')])
return _cadapters
@property
def wsgi_admin_script(self):
raise NotImplementedError
@property
def wsgi_public_script(self):
raise NotImplementedError
@property
def wsgi_container_name(self):
return self.service_name
@property
def wsgi_service_name(self):
return f'{self.service_name}-wsgi'
@property
def wsgi_conf(self):
return f'/etc/apache2/sites-available/{self.wsgi_service_name}.conf'
@property
def wsgi_service_name(self):
return f'wsgi-{self.service_name}'
@property
def public_ingress_port(self):
raise NotImplementedError
@property
def ingress_config(self):
# Most charms probably won't (or shouldn't) expose service-port
# but use it if its there.
port = self.model.config.get('service-port', self.public_ingress_port)
svc_hostname = self.model.config.get(
'os-public-hostname',
self.service_name)
return {
'service-hostname': svc_hostname,
'service-name': self.app.name,
'service-port': port}
def setup_event_handlers(self):
handlers = super().setup_event_handlers()
handlers.extend([
self.setup_db_event_handler(),
self.setup_ingress_event_handler()])
return handlers
def setup_db_event_handler(self):
logger.debug('Setting up DB event handler')
relation_name = f'{self.service_name}-db'
self.db = MySQLConsumer(
self,
f'{self.service_name}-db',
{"mysql": ">=8"})
self.adapters.add_relation_adapter(
self.db,
relation_name)
db_relation_event = getattr(
self.on,
f'{self.handler_prefix}_db_relation_changed')
self.framework.observe(db_relation_event,
self._on_database_changed)
return self.db
def _on_database_changed(self, event) -> None:
"""Handles database change events."""
# self.unit.status = model.MaintenanceStatus('Updating database '
# 'configuration')
databases = self.db.databases()
logger.info(f'Received databases: {databases}')
if not databases:
logger.info('Requesting a new database...')
# The mysql-k8s operator creates a database using the relation
# information in the form of:
# db_{relation_id}_{partial_uuid}_{name_suffix}
# where name_suffix defaults to "". Specify it to the name of the
# current app to make it somewhat understandable as to what this
# database actually is for.
# NOTE(wolsen): database name cannot contain a '-'
name_suffix = self.app.name.replace('-', '_')
self.db.new_database(name_suffix=name_suffix)
return
credentials = self.db.credentials()
logger.info(f'Received credentials: {credentials}')
self._state.db_ready = True
self.configure_charm()
@property
def db_ready(self):
"""Returns True if the remote database has been configured and is
ready for access from the local service.
:returns: True if the database is ready to be accessed, False otherwise
:rtype: bool
"""
return self._state.db_ready
def setup_ingress_event_handler(self):
logger.debug('Setting up ingress event handler')
self.ingress_public = IngressRequires(
self,
self.ingress_config)
return self.ingress_public
def _on_service_pebble_ready(self, event: PebbleReadyEvent) -> None:
container = event.workload
container.add_layer(
self.service_name,
self.get_apache_layer(),
combine=True)
logger.debug(f'Plan: {container.get_plan()}')
def get_apache_layer(self):
"""Apache WSGI service
:returns: pebble layer configuration for wsgi services
:rtype: dict
"""
return {
'summary': f'{self.service_name} layer',
'description': 'pebble config layer for apache wsgi',
'services': {
f'{self.wsgi_service_name}': {
'override': 'replace',
'summary': f'{self.service_name} wsgi',
'command': '/usr/sbin/apache2ctl -DFOREGROUND',
'startup': 'disabled',
},
},
}
def start_wsgi(self):
container = self.unit.get_container(self.wsgi_container_name)
if not container:
logger.debug(f'{self.wsgi_container_name} container is not ready. '
'Cannot start wgi service.')
return
service = container.get_service(self.wsgi_service_name)
if service.is_running():
container.stop(self.wsgi_service_name)
container.start(self.wsgi_service_name)
def configure_charm(self):
self._do_bootstrap()
self.unit.status = model.ActiveStatus()
self._state.bootstrapped = True
def _do_bootstrap(self):
"""Checks the services to see which services need to run depending
on the current state."""
if self.is_bootstrapped():
logger.debug(f'{self.service_name} is already bootstrapped')
return
if not self.db_ready:
logger.debug('Database not ready, not bootstrapping')
self.unit.status = model.BlockedStatus('Waiting for database')
return
if not self.unit.is_leader():
logger.debug('Deferring bootstrap to leader unit')
self.unit.status = model.BlockedStatus('Waiting for leader to '
'bootstrap keystone')
return
container = self.unit.get_container(self.wsgi_container_name)
if not container:
logger.debug(f'{self.wsgi_container_name} container is not ready. Deferring bootstrap')
return
# Write the config files to the container
self.write_config()
try:
sunbeam_cprocess.check_output(
container,
f'a2ensite {self.wsgi_service_name} && sleep 1')
except sunbeam_cprocess.ContainerProcessError:
logger.exception(f'Failed to enable {self.wsgi_service_name} site in apache')
# ignore for now - pebble is raising an exited too quickly, but it
# appears to work properly.
self.start_wsgi()
def is_bootstrapped(self):
"""Returns True if the instance is bootstrapped.
:returns: True if the keystone service has been bootstrapped,
False otherwise
:rtype: bool
"""
return self._state.bootstrapped

View File

@ -0,0 +1,365 @@
# Copyright 2021, Canonical Ltd.
#
# 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 subprocess
import textwrap
import time
import typing
import weakref
from ops import model
from ops import pebble
import uuid
import logging
logger = logging.getLogger(__name__)
# Unknown return code is a large negative number outside the usual range of a
# process exit code
RETURN_CODE_UNKNOWN = -1000
class ContainerProcess(object):
"""A process that has finished running.
This is returned by an invocation to run()
:param container: the container the process was running in
:type container: model.Container
:param process_name: the name of the process the container is running as
:type process_name: str
:param tmp_dir: the dir containing the location of process files
:type tmp_dir: str
"""
def __init__(self, container: model.Container, process_name: str,
tmp_dir: str):
self.container = weakref.proxy(container)
self.process_name = process_name
self._returncode = RETURN_CODE_UNKNOWN
self.tmp_dir = tmp_dir
self.stdout_file = f'{tmp_dir}/{process_name}.stdout'
self.stderr_file = f'{tmp_dir}/{process_name}.stderr'
self._env = dict()
self.env_file = f'{tmp_dir}/{process_name}.env'
self.rc_file = f'{tmp_dir}/{process_name}.rc'
self._cleaned = False
@property
def stdout(self) -> typing.Union[typing.BinaryIO, typing.TextIO]:
return self.container.pull(f'{self.stdout_file}')
@property
def stderr(self) -> typing.Union[typing.BinaryIO, typing.TextIO]:
return self.container.pull(f'{self.stderr_file}')
@property
def env(self) -> typing.Dict[str, str]:
if self._env:
return self._env
with self.container.pull(f'{self.env_file}') as f:
for env_vars in f.read().split(b'\n'):
key_values = env_vars.split(b'=', 1)
self._env[key_values[0]] = key_values[1]
return self._env
@property
def returncode(self) -> int:
if self._returncode == RETURN_CODE_UNKNOWN:
self._returncode = self._get_returncode()
return self._returncode
def _get_returncode(self):
"""Reads the contents of the returncode file"""
try:
with self.container.pull(f'{self.rc_file}') as text:
return int(text.read())
except pebble.PathError:
# If the rc file doesn't exist within the container, then the
# process is either running or failed without capturing output
return RETURN_CODE_UNKNOWN
@property
def completed(self) -> bool:
return self._returncode != RETURN_CODE_UNKNOWN
def check_returncode(self):
"""Raise CalledProcessError if the exit code is non-zero."""
if self.returncode:
stdout = None
stderr = None
try:
stdout = self.stdout.read()
except pebble.PathError:
pass
try:
stderr = self.stderr.read()
except pebble.PathError:
pass
raise CalledProcessError(self.returncode, self.process_name,
stdout, stderr)
def wait(self, timeout: int = 30) -> None:
"""Waits for the process to complete.
Waits for the process to complete. If the process has not completed
within the timeout specified, this method will raise a TimeoutExpired
exception.
:param timeout: the number of seconds to wait before timing out
:type timeout: int
"""
timeout_at = time.time() + timeout
while not self.completed and time.time() < timeout_at:
try:
self._returncode = self._get_returncode()
if self.completed:
return
else:
time.sleep(0.2)
except pebble.PathError:
# This happens while the process is still running
# Sleep a moment and try again
time.sleep(0.2)
raise TimeoutExpired(self.process_name, timeout)
def cleanup(self) -> None:
"""Clean up process files left on the container.
Attempts to cleanup the process artifacts left on the container. This
will remove the directory containing the stdout, stderr, rc and env
files generated.
:raises pebble.PathError: when the path has already been cleand up.
"""
if self._cleaned:
return
self.container.remove_path(f'{self.tmp_dir}', recursive=True)
def __del__(self):
"""On destruction of this process, we'll attempt to clean up left over
process files.
"""
try:
self.cleanup()
except pebble.PathError:
pass
class ContainerProcessError(Exception):
"""Base class for exceptions raised within this module."""
pass
class CalledProcessError(ContainerProcessError):
"""Raised when an error occurs running a process in a container and
the check=True has been passed to raise an error on failure.
:param returncode: the exit code from the program
:type returncode: int
:param cmd: the command that was run
:type cmd: str or list
:param stdout: the output of the command on standard out
:type stdout: str
:param stderr: the output of the command on standard err
:type stderr: str
"""
def __init__(self, returncode: int, cmd: typing.Union[str, list],
stdout: str = None, stderr: str = None):
self.returncode = returncode
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
class TimeoutExpired(ContainerProcessError):
"""This exception is raised when the timeout expires while waiting for a
container process.
:param cmd: the command that was run
:type cmd: list
:param timeout: the configured timeout for the command
:type timeout: int
"""
def __init__(self, cmd: typing.Union[str, list], timeout: int):
self.cmd = cmd
self.timeout = timeout
def __str__(self):
return f"Command '{self.cmd}' timed out after {self.timeout} seconds"
def run(container: model.Container, args: typing.List[str],
timeout: int = 30, check: bool = False,
env: dict = None, service_name: str = None) -> ContainerProcess:
"""Run command with arguments in the specified container.
Run a command in the specified container and returns a
subprocess.CompletedProcess instance containing the command which
was run (args), returncode, and stdout and stderr. When the check
option is True and the process exits with a non-zero exit code, a
CalledProcessError will be raised containing the cmd, returncode,
stdout and stderr.
:param container: the container to run the command in
:type container: model.Container
:param args: the command to run in the container
:type args: str or list
:param timeout: the timeout of the process in seconds
:type timeout: int
:param check: when True, raise an exception on a non-zero exit code
:type check: bool
:param env: environment variables to pass to the process
:type env: dict
:param service_name: name of the service
:type service_name: str
:returns: CompletedProcess the completed process
:rtype: ContainerProcess
"""
if not container:
raise ValueError('container cannot be None')
if not isinstance(container, model.Container):
raise ValueError('container must be of type ops.model.Container, '
f'not of type {type(container)}')
if isinstance(args, str):
if service_name is None:
service_name = args.split(' ')[0]
service_name = service_name.split('/')[-1]
cmdline = args
elif isinstance(args, list):
if service_name is None:
service_name = args[0].split('/')[-1]
cmdline = subprocess.list2cmdline(args)
else:
raise ValueError('args are expected to be a str or a list of str.'
f' Provided {type(args)}')
tmp_dir = f'/tmp/{service_name}-{str(uuid.uuid4()).split("-")[0]}'
process = ContainerProcess(container, service_name, tmp_dir)
command = f"""\
#!/bin/bash
mkdir -p {tmp_dir}
echo $(env) > {process.env_file}
{cmdline} 2> {process.stderr_file} 1> {process.stdout_file}
rc=$?
echo $rc > {process.rc_file}
exit $rc
"""
command = textwrap.dedent(command)
container.push(path=f'/tmp/{service_name}.sh', source=command,
encoding='utf-8', permissions=0o755)
logger.debug(f'Adding layer for {service_name} to run command '
f'{cmdline}')
container.add_layer('process_layer', {
'summary': 'container process runner',
'description': 'layer for running single-shot commands (kinda)',
'services': {
service_name: {
'override': 'replace',
'summary': cmdline,
'command': f'/tmp/{service_name}.sh',
'startup': 'disabled',
'environment': env or {},
}
}
}, combine=True)
timeout_at = time.time() + timeout
try:
# Start the service which will run the command.
logger.debug(f'Starting {service_name} via pebble')
# TODO(wolsen) this is quite naughty, but the container object
# doesn't provide us access to the pebble layer to specify
# timeouts and such. Some commands may need a longer time to
# start, and as such I'm using the private internal reference
# in order to be able to specify the timeout itself.
container._pebble.start_services([service_name], # noqa
timeout=float(timeout))
except pebble.ChangeError:
# Check to see if the command has timed out and if so, raise
# the TimeoutExpired.
if time.time() >= timeout_at:
logger.error(f'Command {cmdline} could not start out after '
f'{timeout} seconds in container '
f'{container.name}')
raise TimeoutExpired(args, timeout)
# Note, this could be expected.
logger.exception(f'Error running {service_name}')
logger.debug('Waiting for process completion...')
process.wait(timeout)
# It appears that pebble services are still active after the command
# has finished. Feels like a bug, but let's stop it.
try:
service = container.get_service(service_name)
if service.is_running():
container.stop(service_name)
except pebble.ChangeError as e:
# Eat the change error that might occur. This was a best effort
# attempt to ensure the process is stopped
logger.exception(f'Failed to stop service {service_name}', e)
if check:
process.check_returncode()
return process
def call(container: model.Container, args: typing.Union[str, list],
env: dict = None, timeout: int = 30) -> int:
"""Runs a command in the container.
The command will run until the process completes (either normally or
abnormally) or until the timeout expires.
:param container: the container to run the command in
:type container: model.Container
:param args: arguments to pass to the commandline
:type args: str or list of strings
:param env: environment variables for the process
:type env: dictionary of environment variables
:param timeout: number of seconds the command should complete in before
timing out
:type timeout: int
:returns: the exit code of the process
:rtype: int
"""
return run(container, args, env=env, timeout=timeout).returncode
def check_call(container: model.Container, args: typing.Union[str, list],
env: dict = None, timeout: int = 30,
service_name: str = None) -> None:
run(container, args, env=env, check=True, timeout=timeout,
service_name=service_name)
def check_output(container: model.Container, args: typing.Union[str, list],
env: dict = None, timeout: int = 30,
service_name: str = None) -> str:
process = run(container, args, env=env, check=True, timeout=timeout,
service_name=service_name)
with process.stdout as stdout:
return stdout.read()

View File

@ -0,0 +1,73 @@
# Copyright 2021, Canonical Ltd.
#
# 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 logging
from contextlib import contextmanager
from ops.model import BlockedStatus
logger = logging.getLogger(__name__)
class GuardException(Exception):
pass
class BlockedException(Exception):
pass
@contextmanager
def guard(charm: 'CharmBase',
section: str,
handle_exception: bool = True,
log_traceback: bool = True,
**__):
"""Context manager to handle errors and bailing out of an event/hook.
The nature of Juju is that all the information may not be available to run
a set of actions. This context manager allows a section of code to be
'guarded' so that it can be bailed at any time.
It also handles errors which can be interpreted as a Block rather than the
charm going into error.
:param charm: the charm class (so that unit status can be set)
:param section: the name of the section (for debugging/info purposes)
:handle_exception: whether to handle the exception to a BlockedStatus()
:log_traceback: whether to log the traceback for debugging purposes.
:raises: Exception if handle_exception is False
"""
logger.info("Entering guarded section: '%s'", section)
try:
yield
logging.info("Completed guarded section fully: '%s'", section)
except GuardException as e:
logger.info("Guarded Section: Early exit from '%s' due to '%s'.",
section, str(e))
except BlockedException as e:
logger.warning(
"Charm is blocked in section '%s' due to '%s'", section, str(e))
charm.unit.status = BlockedStatus(e.msg)
except Exception as e:
# something else went wrong
if handle_exception:
logging.error("Exception raised in secion '%s': %s",
section, str(e))
if log_traceback:
import traceback
logging.error(traceback.format_exc())
charm.unit.status = BlockedStatus(
"Error in charm (see logs): {}".format(str(e)))
return
raise

View File

@ -0,0 +1,143 @@
# Copyright 2021, Canonical Ltd.
#
# 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 logging
import os
from collections import defaultdict
from charmhelpers.contrib.openstack.templating import (
OSConfigException,
OSConfigRenderer,
get_loader)
import jinja2
# from jinja2 import FileSystemLoader, ChoiceLoader, Environment, exceptions
log = logging.getLogger(__name__)
def get_container(containers, name):
container = None
for c in containers:
if c.name == name:
container = c
return c
def sidecar_config_render(containers, container_configs, template_dir,
openstack_release, adapters):
loader = get_loader(template_dir, openstack_release)
_tmpl_env = jinja2.Environment(loader=loader)
for config in container_configs:
for container_name in config.container_names:
try:
template = _tmpl_env.get_template(
os.path.basename(config.path))
except jinja2.exceptions.TemplateNotFound:
template = _tmpl_env.get_template(
os.path.basename(config.path) + '.j2')
container = get_container(containers, container_name)
contents = template.render(adapters)
kwargs = {
'user': config.user,
'group': config.group}
container.push(config.path, contents, **kwargs)
log.debug(f'Wrote template {config.path} in container '
f'{container.name}.')
class SidecarConfigRenderer(OSConfigRenderer):
"""
This class provides a common templating system to be used by OpenStack
sidecar charms.
"""
def __init__(self, templates_dir, openstack_release):
super(SidecarConfigRenderer, self).__init__(templates_dir,
openstack_release)
class _SidecarConfigRenderer(OSConfigRenderer):
"""
This class provides a common templating system to be used by OpenStack
sidecar charms.
"""
def __init__(self, templates_dir, openstack_release):
super(SidecarConfigRenderer, self).__init__(templates_dir,
openstack_release)
self.config_to_containers = defaultdict(set)
self.owner_info = defaultdict(set)
def _get_template(self, template):
"""
"""
self._get_tmpl_env()
if not template.endswith('.j2'):
template += '.j2'
template = self._tmpl_env.get_template(template)
log.debug(f'Loaded template from {template.filename}')
return template
def register(self, config_file, contexts, config_template=None,
containers=None, user=None, group=None):
"""
"""
# NOTE(wolsen): Intentionally overriding base class to raise an error
# if this is accidentally used instead.
if containers is None:
raise ValueError('One or more containers must be provided')
super().register(config_file, contexts, config_template)
# Register user/group info. There's a better way to do this for sure
if user or group:
self.owner_info[config_file] = (user, group)
for container in containers:
self.config_to_containers[config_file].add(container)
log.debug(f'Registered config file "{config_file}" for container '
f'{container}')
def write(self, config_file, container):
"""
"""
containers = self.config_to_containers.get(config_file)
if not containers or container.name not in containers:
log.error(f'Config file {config_file} not registered for '
f'container {container.name}')
raise OSConfigException
contents = self.render(config_file)
owner_info = self.owner_info.get(config_file)
kwargs = {}
log.debug(f'Got owner_info of {owner_info}')
if owner_info:
user, group = owner_info
kwargs['user'] = user
kwargs['group'] = group
container.push(config_file, contents, **kwargs)
log.debug(f'Wrote template {config_file} in container '
f'{container.name}.')
def write_all(self, container=None):
for config_file, containers in self.config_to_containers.items():
if container:
if container.name not in containers:
continue
self.write(config_file, container)
else:
for c in containers:
self.write(config_file, c)

5
fetch-libs.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress
charmcraft fetch-lib charms.mysql.v1.mysql

2
lib/charms/NOTE.txt Normal file
View File

@ -0,0 +1,2 @@
mysql lib does not seem to be published
so as a tectical solution keep it in tree

View File

@ -0,0 +1,105 @@
"""
## Overview
This document explains how to integrate with the MySQL charm for the purposes of consuming a mysql database. It also explains how alternative implementations of the MySQL charm may maintain the same interface and be backward compatible with all currently integrated charms. Finally this document is the authoritative reference on the structure of relation data that is shared between MySQL charms and any other charm that intends to use the database.
## Consumer Library Usage
The MySQL charm library uses the [Provider and Consumer](https://ops.readthedocs.io/en/latest/#module-ops.relation) objects from the Operator Framework. Charms that would like to use a MySQL database must use the `MySQLConsumer` object from the charm library. Using the `MySQLConsumer` object requires instantiating it, typically in the constructor of your charm. The `MySQLConsumer` constructor requires the name of the relation over which a database will be used. This relation must use the `mysql_datastore` interface. In addition the constructor also requires a `consumes` specification, which is a dictionary with key `mysql` (also see Provider Library Usage below) and a value that represents the minimum acceptable version of MySQL. This version string can be in any format that is compatible with the Python [Semantic Version module](https://pypi.org/project/semantic-version/). For example, assuming your charm consumes a database over a rlation named "monitoring", you may instantiate `MySQLConsumer` as follows:
from charms.mysql_k8s.v0.mysql import MySQLConsumer
def __init__(self, *args):
super().__init__(*args)
...
self.mysql_consumer = MySQLConsumer(
self, "monitoring", {"mysql": ">=8"}
)
...
This example hard codes the consumes dictionary argument containing the minimal MySQL version required, however you may want to consider generating this dictionary by some other means, such as a `self.consumes` property in your charm. This is because the minimum required MySQL version may change when you upgrade your charm. Of course it is expected that you will keep this version string updated as you develop newer releases of your charm. If the version string can be determined at run time by inspecting the actual deployed version of your charmed application, this would be ideal.
An instantiated `MySQLConsumer` object may be used to request new databases using the `new_database()` method. This method requires no arguments unless you require multiple databases. If multiple databases are requested, you must provide a unique `name_suffix` argument. For example:
def _on_database_relation_joined(self, event):
self.mysql_consumer.new_database(name_suffix="db1")
self.mysql_consumer.new_database(name_suffix="db2")
The `address`, `port`, `databases`, and `credentials` methods can all be called
to get the relevant information from the relation data.
"""
# !/usr/bin/env python3
# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.
import json
import uuid
import logging
from ops.relation import ConsumerBase
LIBID = "abcdef1234" # Will change when uploding the charm to charmhub
LIBAPI = 1
LIBPATCH = 0
logger = logging.getLogger(__name__)
class MySQLConsumer(ConsumerBase):
"""
MySQLConsumer lib class
"""
def __init__(self, charm, name, consumes, multi=False):
super().__init__(charm, name, consumes, multi)
self.charm = charm
self.relation_name = name
def databases(self, rel_id=None) -> list:
"""
List of currently available databases
Returns:
list: list of database names
"""
rel = self.framework.model.get_relation(self.relation_name, rel_id)
relation_data = rel.data[rel.app]
dbs = relation_data.get("databases")
databases = json.loads(dbs) if dbs else []
return databases
def credentials(self, rel_id=None) -> dict:
"""
Dictionary of credential information to access databases
Returns:
dict: dictionary of credential information including username,
password and address
"""
rel = self.framework.model.get_relation(self.relation_name, rel_id)
relation_data = rel.data[rel.app]
data = relation_data.get("data")
data = json.loads(data) if data else {}
credentials = data.get("credentials")
return credentials
def new_database(self, rel_id=None, name_suffix=""):
"""
Request creation of an additional database
"""
if not self.charm.unit.is_leader():
return
rel = self.framework.model.get_relation(self.relation_name, rel_id)
if name_suffix:
name_suffix = "_{}".format(name_suffix)
rid = str(uuid.uuid4()).split("-")[-1]
db_name = "db_{}_{}_{}".format(rel.id, rid, name_suffix)
logger.debug("CLIENT REQUEST %s", db_name)
rel_data = rel.data[self.charm.app]
dbs = rel_data.get("databases")
dbs = json.loads(dbs) if dbs else []
dbs.append(db_name)
rel.data[self.charm.app]["databases"] = json.dumps(dbs)

3
requirements-dev.txt Normal file
View File

@ -0,0 +1,3 @@
-r requirements.txt
coverage
flake8

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
charmhelpers
jinja2
kubernetes
git+https://github.com/canonical/operator@2875e73e#egg=ops
python-keystoneclient
git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack

18
setup.cfg Normal file
View File

@ -0,0 +1,18 @@
[metadata]
name = advanced_sunbeam_openstack
summary = Charm lib for OpenStack Charms using operator framework
version = 0.0.1.dev1
description-file =
README.rst
author = OpenStack Charmers
author-email = openstack-charmers@lists.ubuntu.com
classifier =
Development Status :: 2 - Pre-Alpha
Intended Audience :: Developers
Topic :: System
Topic :: System :: Installation/Setup
opic :: System :: Software Distribution
Programming Language :: Python :: 3
Programming Language :: Python :: 3.5
License :: OSI Approved :: Apache Software License

39
setup.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright 2021 Canonical Ltd.
#
# 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.
"""Module used to setup the advanced_sunbeam_openstack framework."""
from __future__ import print_function
from setuptools import setup, find_packages
version = "0.0.1.dev1"
install_require = [
'charmhelpers',
'ops',
]
tests_require = [
'tox >= 2.3.1',
]
setup(
license='Apache-2.0: http://www.apache.org/licenses/LICENSE-2.0',
packages=find_packages(exclude=["unit_tests"]),
zip_safe=False,
install_requires=install_require,
)

17
test-requirements.txt Normal file
View File

@ -0,0 +1,17 @@
# This file is managed centrally. If you find the need to modify this as a
# one-off, please don't. Intead, consult #openstack-charms and ask about
# requirements management in charms via bot-control. Thank you.
charm-tools>=2.4.4
coverage>=3.6
mock>=1.2
flake8>=2.2.4,<=2.4.1
pyflakes==2.1.1
stestr>=2.2.0
requests>=2.18.4
psutil
# oslo.i18n dropped py35 support
oslo.i18n<4.0.0
git+https://github.com/openstack-charmers/zaza.git#egg=zaza
git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack
pytz # workaround for 14.04 pip/tox
pyudev # for ceph-* charm unit tests (not mocked?)

141
tox.ini Normal file
View File

@ -0,0 +1,141 @@
# Operator charm (with zaza): tox.ini
[tox]
envlist = pep8,fetch,py3
skipsdist = True
# NOTE: Avoid build/test env pollution by not enabling sitepackages.
sitepackages = False
# NOTE: Avoid false positives by not skipping missing interpreters.
skip_missing_interpreters = False
# NOTES:
# * We avoid the new dependency resolver by pinning pip < 20.3, see
# https://github.com/pypa/pip/issues/9187
# * Pinning dependencies requires tox >= 3.2.0, see
# https://tox.readthedocs.io/en/latest/config.html#conf-requires
# * It is also necessary to pin virtualenv as a newer virtualenv would still
# lead to fetching the latest pip in the func* tox targets, see
# https://stackoverflow.com/a/38133283
requires = pip < 20.3
virtualenv < 20.0
# NOTE: https://wiki.canonical.com/engineering/OpenStack/InstallLatestToxOnOsci
minversion = 3.2.0
[testenv]
setenv = VIRTUAL_ENV={envdir}
PYTHONHASHSEED=0
CHARM_DIR={envdir}
install_command =
pip install {opts} {packages}
commands = stestr run --slowest {posargs}
whitelist_externals =
git
add-to-archive.py
fetch-libs.sh
bash
charmcraft
passenv = HOME TERM CS_* OS_* TEST_*
deps = -r{toxinidir}/test-requirements.txt
[testenv:fetch]
basepython = python3
deps =
commands =
./fetch-libs.sh
[testenv:py35]
basepython = python3.5
# python3.5 is irrelevant on a focal+ charm.
commands = /bin/true
[testenv:py36]
basepython = python3.6
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
[testenv:py37]
basepython = python3.7
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
[testenv:py38]
basepython = python3.8
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
[testenv:py3]
basepython = python3
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
[testenv:pep8]
basepython = python3
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
commands = flake8 {posargs} src unit_tests tests
[testenv:cover]
# Technique based heavily upon
# https://github.com/openstack/nova/blob/master/tox.ini
basepython = python3
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
setenv =
{[testenv]setenv}
PYTHON=coverage run
commands =
coverage erase
stestr run --slowest {posargs}
coverage combine
coverage html -d cover
coverage xml -o cover/coverage.xml
coverage report
[coverage:run]
branch = True
concurrency = multiprocessing
parallel = True
source =
.
omit =
.tox/*
*/charmhelpers/*
unit_tests/*
[testenv:venv]
basepython = python3
commands = {posargs}
[testenv:build]
basepython = python3
deps = -r{toxinidir}/build-requirements.txt
commands =
charmcraft build
[testenv:func-noop]
basepython = python3
commands =
functest-run-suite --help
[testenv:func]
basepython = python3
commands =
functest-run-suite --keep-model
[testenv:func-smoke]
basepython = python3
commands =
functest-run-suite --keep-model --smoke
[testenv:func-dev]
basepython = python3
commands =
functest-run-suite --keep-model --dev
[testenv:func-target]
basepython = python3
commands =
functest-run-suite --keep-model --bundle {posargs}
[flake8]
# Ignore E902 because the unit_tests directory is missing in the built charm.
ignore = E402,E226,E902

0
unit_tests/__init__.py Normal file
View File

301
unit_tests/test_core.py Normal file
View File

@ -0,0 +1,301 @@
#!/usr/bin/env python3
# Copyright 2020 Canonical Ltd.
#
# 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
from mock import patch
import unittest
import sys
sys.path.append('lib') # noqa
sys.path.append('src') # noqa
from ops.testing import Harness
import advanced_sunbeam_openstack.core as core
CHARM_CONFIG = {
'debug': 'true'}
CHARM_METADATA = '''
name: my-service
version: 3
bases:
- name: ubuntu
channel: 20.04/stable
tags:
- openstack
- identity
- misc
subordinate: false
requires:
my-service-db:
interface: mysql_datastore
limit: 1
ingress:
interface: ingress
peers:
peers:
interface: mysvc-peer
containers:
my-service:
resource: mysvc-image
mounts:
- storage: db
location: /var/lib/mysvc
storage:
logs:
type: filesystem
db:
type: filesystem
resources:
mysvc-image:
type: oci-image
'''
class CharmTestCase(unittest.TestCase):
def setUp(self, obj, patches):
super().setUp()
self.patches = patches
self.obj = obj
self.patch_all()
def patch(self, method):
_m = patch.object(self.obj, method)
mock = _m.start()
self.addCleanup(_m.stop)
return mock
def patch_all(self):
for method in self.patches:
setattr(self, method, self.patch(method))
class MyCharm(core.OSBaseOperatorCharm):
openstack_release = 'diablo'
service_name = 'my-service'
def __init__(self, framework):
super().__init__(framework)
self.seen_events = []
self.render_calls = []
def renderer(self, containers, container_configs, template_dir,
openstack_release, adapters):
self.render_calls.append(
(
containers,
container_configs,
template_dir,
openstack_release,
adapters))
def _log_event(self, event):
self.seen_events.append(type(event).__name__)
def _on_service_pebble_ready(self, event):
self._log_event(event)
def _on_config_changed(self, event):
self._log_event(event)
@property
def public_ingress_port(self):
return 789
class TestOSBaseOperatorCharm(unittest.TestCase):
def setUp(self):
self.harness = Harness(
MyCharm,
meta=CHARM_METADATA
)
self.addCleanup(self.harness.cleanup)
self.harness.update_config(CHARM_CONFIG)
self.harness.begin()
def set_pebble_ready(self):
container = self.harness.model.unit.get_container("my-service")
# Emit the PebbleReadyEvent
self.harness.charm.on.my_service_pebble_ready.emit(container)
def test_pebble_ready_handler(self):
self.assertEqual(self.harness.charm.seen_events, [])
self.set_pebble_ready()
self.assertEqual(self.harness.charm.seen_events, ['PebbleReadyEvent'])
def test_write_config(self):
self.set_pebble_ready()
self.harness.charm.write_config()
self.assertEqual(
self.harness.charm.render_calls[0],
(
[self.harness.model.unit.get_container("my-service")],
[],
'src/templates',
'diablo',
self.harness.charm.adapters))
def test_handler_prefix(self):
self.assertEqual(
self.harness.charm.handler_prefix,
'my_service')
def test_container_names(self):
self.assertEqual(
self.harness.charm.container_names,
['my-service'])
def test_template_dir(self):
self.assertEqual(
self.harness.charm.template_dir,
'src/templates')
class MyAPICharm(core.OSBaseOperatorAPICharm):
openstack_release = 'diablo'
service_name = 'my-service'
wsgi_admin_script = '/bin/wsgi_admin'
wsgi_public_script = '/bin/wsgi_public'
def __init__(self, framework):
self.seen_events = []
self.render_calls = []
super().__init__(framework)
def renderer(self, containers, container_configs, template_dir,
openstack_release, adapters):
self.render_calls.append(
(
containers,
container_configs,
template_dir,
openstack_release,
adapters))
def _log_event(self, event):
self.seen_events.append(type(event).__name__)
def _on_service_pebble_ready(self, event):
super()._on_service_pebble_ready(event)
self._log_event(event)
def _on_config_changed(self, event):
self._log_event(event)
@property
def public_ingress_port(self):
return 789
class TestOSBaseOperatorAPICharm(CharmTestCase):
PATCHES = [
'sunbeam_cprocess',
]
def setUp(self):
super().setUp(core, self.PATCHES)
self.sunbeam_cprocess.ContainerProcessError = Exception
self.harness = Harness(
MyAPICharm,
meta=CHARM_METADATA
)
self.addCleanup(self.harness.cleanup)
self.harness.update_config(CHARM_CONFIG)
self.harness.begin()
def add_base_db_relation(self):
rel_id = self.harness.add_relation('my-service-db', 'mysql')
self.harness.add_relation_unit(
rel_id,
'mysql/0')
self.harness.add_relation_unit(
rel_id,
'mysql/0')
self.harness.update_relation_data(
rel_id,
'mysql/0',
{'ingress-address': '10.0.0.3'})
return rel_id
def add_db_relation_credentials(self, rel_id):
self.harness.update_relation_data(
rel_id,
'mysql',
{
'databases': json.dumps(['db1']),
'data': json.dumps({
'credentials': {
'username': 'foo',
'password': 'hardpassword',
'address': '10.0.0.10'}})})
def set_pebble_ready(self):
self.harness.container_pebble_ready('my-service')
def test_write_config(self):
self.set_pebble_ready()
self.harness.charm.write_config()
self.assertEqual(
self.harness.charm.render_calls[0],
(
[self.harness.model.unit.get_container("my-service")],
[
core.ContainerConfigFile(
container_names=['my-service'],
path=('/etc/apache2/sites-available/'
'wsgi-my-service.conf'),
user='root',
group='root')],
'src/templates',
'diablo',
self.harness.charm.adapters))
def test__on_database_changed(self):
self.harness.set_leader()
self.set_pebble_ready()
rel_id = self.add_base_db_relation()
rel_data = self.harness.get_relation_data(
rel_id,
'my-service')
requested_db = json.loads(rel_data['databases'])[0]
self.assertRegex(requested_db, r'^db_.*my_service$')
def test_DBAdapter(self):
self.harness.set_leader()
self.set_pebble_ready()
rel_id = self.add_base_db_relation()
self.add_db_relation_credentials(rel_id)
self.assertEqual(
self.harness.charm.adapters.wsgi_config.wsgi_admin_script,
'/bin/wsgi_admin')
self.assertEqual(
self.harness.charm.adapters.my_service_db.database_password,
'hardpassword')
self.assertEqual(
self.harness.charm.adapters.options.debug,
'true')