Remove RelationAdapters etc
Remove RelationAdapters and add contexts to relation handlers. Other thing of note is that default handlers are registered based on a charms metadata.
This commit is contained in:
parent
e2f63e897d
commit
15a9842bd2
@ -1,178 +0,0 @@
|
|||||||
import logging
|
|
||||||
import ops_openstack.adapters
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ready(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ready(self):
|
|
||||||
try:
|
|
||||||
creds = self.relation.credentials()
|
|
||||||
except AttributeError:
|
|
||||||
return False
|
|
||||||
return bool(creds)
|
|
||||||
|
|
||||||
|
|
||||||
class AMQPAdapter(ops_openstack.adapters.OpenStackOperRelationAdapter):
|
|
||||||
|
|
||||||
DEFAULT_PORT = "5672"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def port(self):
|
|
||||||
"""Return the AMQP port
|
|
||||||
|
|
||||||
:returns: AMQP port number
|
|
||||||
:rtype: string
|
|
||||||
"""
|
|
||||||
return self.relation.ssl_port or self.DEFAULT_PORT
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hosts(self):
|
|
||||||
"""
|
|
||||||
Comma separated list of hosts that should be used
|
|
||||||
to access RabbitMQ.
|
|
||||||
"""
|
|
||||||
hosts = self.relation.hostnames
|
|
||||||
if len(hosts) > 1:
|
|
||||||
return ','.join(hosts)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def transport_url(self) -> str:
|
|
||||||
"""
|
|
||||||
oslo.messaging formatted transport URL
|
|
||||||
|
|
||||||
:returns: oslo.messaging formatted transport URL
|
|
||||||
:rtype: string
|
|
||||||
"""
|
|
||||||
hosts = self.relation.hostnames
|
|
||||||
transport_url_hosts = ','.join([
|
|
||||||
"{}:{}@{}:{}".format(self.username,
|
|
||||||
self.password,
|
|
||||||
host_, # TODO deal with IPv6
|
|
||||||
self.port)
|
|
||||||
for host_ in hosts
|
|
||||||
])
|
|
||||||
return "rabbit://{}/{}".format(transport_url_hosts, self.vhost)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ready(self):
|
|
||||||
try:
|
|
||||||
creds = self.transport_url
|
|
||||||
except AttributeError:
|
|
||||||
return False
|
|
||||||
return bool(creds)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
264
ops-sunbeam/advanced_sunbeam_openstack/charm.py
Normal file
264
ops-sunbeam/advanced_sunbeam_openstack/charm.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Base classes for defining a charm using the Operator framework.
|
||||||
|
|
||||||
|
This library provided OSBaseOperatorCharm and OSBaseOperatorAPICharm. The
|
||||||
|
charm classes use advanced_sunbeam_openstack.relation_handlers.RelationHandler
|
||||||
|
objects to interact with relations. These objects also provide contexts which
|
||||||
|
can be used when defining templates.
|
||||||
|
|
||||||
|
In addition to the Relation handlers the charm class can also use
|
||||||
|
advanced_sunbeam_openstack.config_contexts.ConfigContext objects which
|
||||||
|
can be used when rendering templates, these are not specific to a relation.
|
||||||
|
|
||||||
|
The charm class interacts with the containers it is managing via
|
||||||
|
advanced_sunbeam_openstack.container_handlers.PebbleHandler. The
|
||||||
|
PebbleHandler defines the pebble layers, manages pushing
|
||||||
|
configuration to the containers and managing the service running
|
||||||
|
in the container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import ops.charm
|
||||||
|
import ops.framework
|
||||||
|
import ops.model
|
||||||
|
|
||||||
|
import advanced_sunbeam_openstack.config_contexts as sunbeam_config_contexts
|
||||||
|
import advanced_sunbeam_openstack.container_handlers as sunbeam_chandlers
|
||||||
|
import advanced_sunbeam_openstack.core as sunbeam_core
|
||||||
|
import advanced_sunbeam_openstack.relation_handlers as sunbeam_rhandlers
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OSBaseOperatorCharm(ops.charm.CharmBase):
|
||||||
|
"""Base charms for OpenStack operators."""
|
||||||
|
|
||||||
|
_state = ops.framework.StoredState()
|
||||||
|
|
||||||
|
def __init__(self, framework):
|
||||||
|
super().__init__(framework)
|
||||||
|
self._state.set_default(bootstrapped=False)
|
||||||
|
self.relation_handlers = self.get_relation_handlers()
|
||||||
|
self.pebble_handlers = self.get_pebble_handlers()
|
||||||
|
self.framework.observe(self.on.config_changed,
|
||||||
|
self._on_config_changed)
|
||||||
|
|
||||||
|
def get_relation_handlers(self) -> List[sunbeam_rhandlers.RelationHandler]:
|
||||||
|
"""Relation handlers for the operator."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_pebble_handlers(self) -> List[sunbeam_chandlers.PebbleHandler]:
|
||||||
|
"""Pebble handlers for the operator."""
|
||||||
|
return [
|
||||||
|
sunbeam_chandlers.PebbleHandler(
|
||||||
|
self,
|
||||||
|
self.service_name,
|
||||||
|
self.service_name,
|
||||||
|
self.container_configs,
|
||||||
|
self.template_dir,
|
||||||
|
self.openstack_release,
|
||||||
|
self.configure_charm)]
|
||||||
|
|
||||||
|
def configure_charm(self, event) -> None:
|
||||||
|
"""Catchall handler to cconfigure charm services."""
|
||||||
|
if not self.relation_handlers_ready():
|
||||||
|
logging.debug("Aborting charm relations not ready")
|
||||||
|
return
|
||||||
|
|
||||||
|
for ph in self.pebble_handlers:
|
||||||
|
if ph.pebble_ready:
|
||||||
|
ph.init_service(self.contexts())
|
||||||
|
|
||||||
|
for ph in self.pebble_handlers:
|
||||||
|
if not ph.service_ready:
|
||||||
|
logging.debug("Aborting container service not ready")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.bootstrapped():
|
||||||
|
self._do_bootstrap()
|
||||||
|
|
||||||
|
self.unit.status = ops.model.ActiveStatus()
|
||||||
|
self._state.bootstrapped = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def container_configs(self) -> List[sunbeam_core.ContainerConfigFile]:
|
||||||
|
"""Container configuration files for the operator."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_contexts(self) -> List[
|
||||||
|
sunbeam_config_contexts.CharmConfigContext]:
|
||||||
|
"""Configuration adapters for the operator."""
|
||||||
|
return [
|
||||||
|
sunbeam_config_contexts.CharmConfigContext(self, 'options')]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handler_prefix(self) -> str:
|
||||||
|
"""Prefix for handlers??"""
|
||||||
|
return self.service_name.replace('-', '_')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def container_names(self):
|
||||||
|
"""Containers that form part of this service."""
|
||||||
|
return [self.service_name]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template_dir(self) -> str:
|
||||||
|
"""Directory containing Jinja2 templates."""
|
||||||
|
return 'src/templates'
|
||||||
|
|
||||||
|
def _on_config_changed(self, event):
|
||||||
|
self.configure_charm(None)
|
||||||
|
|
||||||
|
def containers_ready(self) -> bool:
|
||||||
|
"""Determine whether all containers are ready for configuration."""
|
||||||
|
for ph in self.pebble_handlers:
|
||||||
|
if not ph.service_ready:
|
||||||
|
logger.info(f"Container incomplete: {ph.container_name}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def relation_handlers_ready(self) -> bool:
|
||||||
|
"""Determine whether all relations are ready for use."""
|
||||||
|
for handler in self.relation_handlers:
|
||||||
|
if not handler.ready:
|
||||||
|
logger.info(f"Relation {handler.relation_name} incomplete")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def contexts(self) -> sunbeam_core.OPSCharmContexts:
|
||||||
|
"""Construct context for rendering templates."""
|
||||||
|
ra = sunbeam_core.OPSCharmContexts(self)
|
||||||
|
for handler in self.relation_handlers:
|
||||||
|
if handler.relation_name not in self.meta.relations.keys():
|
||||||
|
logger.info(
|
||||||
|
f"Dropping handler for relation {handler.relation_name}, "
|
||||||
|
"relation not present in charm metadata")
|
||||||
|
continue
|
||||||
|
if handler.ready:
|
||||||
|
ra.add_relation_handler(handler)
|
||||||
|
ra.add_config_contexts(self.config_contexts)
|
||||||
|
return ra
|
||||||
|
|
||||||
|
def _do_bootstrap(self) -> None:
|
||||||
|
"""Bootstrap the service ready for operation.
|
||||||
|
|
||||||
|
This method should be overridden as part of a concrete
|
||||||
|
charm implementation
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def bootstrapped(self) -> bool:
|
||||||
|
"""Determine whether the service has been boostrapped."""
|
||||||
|
return self._state.bootstrapped
|
||||||
|
|
||||||
|
|
||||||
|
class OSBaseOperatorAPICharm(OSBaseOperatorCharm):
|
||||||
|
"""Base class for OpenStack API operators"""
|
||||||
|
|
||||||
|
def __init__(self, framework):
|
||||||
|
super().__init__(framework)
|
||||||
|
self._state.set_default(db_ready=False)
|
||||||
|
|
||||||
|
def get_pebble_handlers(self) -> List[sunbeam_chandlers.PebbleHandler]:
|
||||||
|
"""Pebble handlers for the service"""
|
||||||
|
return [
|
||||||
|
sunbeam_chandlers.WSGIPebbleHandler(
|
||||||
|
self,
|
||||||
|
self.service_name,
|
||||||
|
self.service_name,
|
||||||
|
self.container_configs,
|
||||||
|
self.template_dir,
|
||||||
|
self.openstack_release,
|
||||||
|
self.configure_charm,
|
||||||
|
f'wsgi-{self.service_name}')]
|
||||||
|
|
||||||
|
def get_relation_handlers(self) -> List[sunbeam_rhandlers.RelationHandler]:
|
||||||
|
"""Relation handlers for the service."""
|
||||||
|
handlers = []
|
||||||
|
if 'amqp' in self.meta.relations.keys():
|
||||||
|
self.amqp = sunbeam_rhandlers.AMQPHandler(
|
||||||
|
self,
|
||||||
|
'amqp',
|
||||||
|
self.configure_charm,
|
||||||
|
self.service_name,
|
||||||
|
self.service_name)
|
||||||
|
handlers.append(self.amqp)
|
||||||
|
if f'{self.service_name}-db' in self.meta.relations.keys():
|
||||||
|
self.db = sunbeam_rhandlers.DBHandler(
|
||||||
|
self,
|
||||||
|
f'{self.service_name}-db',
|
||||||
|
self.configure_charm)
|
||||||
|
handlers.append(self.db)
|
||||||
|
if 'ingress' in self.meta.relations.keys():
|
||||||
|
self.ingress = sunbeam_rhandlers.IngressHandler(
|
||||||
|
self,
|
||||||
|
'ingress',
|
||||||
|
self.service_name,
|
||||||
|
self.default_public_ingress_port,
|
||||||
|
self.configure_charm)
|
||||||
|
handlers.append(self.ingress)
|
||||||
|
return handlers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def container_configs(self) -> List[sunbeam_core.ContainerConfigFile]:
|
||||||
|
"""Container configuration files for the service."""
|
||||||
|
_cconfigs = super().container_configs
|
||||||
|
_cconfigs.extend([
|
||||||
|
sunbeam_core.ContainerConfigFile(
|
||||||
|
[self.wsgi_container_name],
|
||||||
|
self.service_conf,
|
||||||
|
self.service_user,
|
||||||
|
self.service_group)])
|
||||||
|
return _cconfigs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_user(self) -> str:
|
||||||
|
"""Service user file and directory ownership."""
|
||||||
|
return self.service_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_group(self) -> str:
|
||||||
|
"""Service group file and directory ownership."""
|
||||||
|
return self.service_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_conf(self) -> str:
|
||||||
|
"""Service default configuration file."""
|
||||||
|
return f'/etc/{self.service_name}/{self.service_name}.conf'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_contexts(self) -> List[sunbeam_config_contexts.ConfigContext]:
|
||||||
|
"""Generate list of configuration adapters for the charm."""
|
||||||
|
_cadapters = super().config_contexts
|
||||||
|
_cadapters.extend([
|
||||||
|
sunbeam_config_contexts.WSGIWorkerConfigContext(
|
||||||
|
self,
|
||||||
|
'wsgi_config')])
|
||||||
|
return _cadapters
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wsgi_container_name(self) -> str:
|
||||||
|
"""Name of the WSGI application container."""
|
||||||
|
return self.service_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_public_ingress_port(self) -> int:
|
||||||
|
"""Port to use for ingress access to service."""
|
||||||
|
raise NotImplementedError
|
58
ops-sunbeam/advanced_sunbeam_openstack/config_contexts.py
Normal file
58
ops-sunbeam/advanced_sunbeam_openstack/config_contexts.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Base classes for defining a charm using the Operator framework.
|
||||||
|
|
||||||
|
ConfigContext objects can be used when rendering templates. They idea is to
|
||||||
|
create reusable contexts which translate charm config, deployment state etc.
|
||||||
|
These are not specific to a relation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigContext():
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def context(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class CharmConfigContext(ConfigContext):
|
||||||
|
"""A context containing all of the charms config options"""
|
||||||
|
|
||||||
|
def context(self) -> dict:
|
||||||
|
return self.charm.config
|
||||||
|
|
||||||
|
|
||||||
|
class WSGIWorkerConfigContext(ConfigContext):
|
||||||
|
|
||||||
|
def context(self) -> dict:
|
||||||
|
"""A context containing WSGI configuration options"""
|
||||||
|
return {
|
||||||
|
'name': self.charm.service_name,
|
||||||
|
'wsgi_admin_script': self.charm.wsgi_admin_script,
|
||||||
|
'wsgi_public_script': self.charm.wsgi_public_script}
|
213
ops-sunbeam/advanced_sunbeam_openstack/container_handlers.py
Normal file
213
ops-sunbeam/advanced_sunbeam_openstack/container_handlers.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Base classes for defining Pebble handlers
|
||||||
|
|
||||||
|
The PebbleHandler defines the pebble layers, manages pushing
|
||||||
|
configuration to the containers and managing the service running
|
||||||
|
in the container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import advanced_sunbeam_openstack.core as sunbeam_core
|
||||||
|
import advanced_sunbeam_openstack.templating as sunbeam_templating
|
||||||
|
import advanced_sunbeam_openstack.cprocess as sunbeam_cprocess
|
||||||
|
import ops.charm
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PebbleHandler(ops.charm.Object):
|
||||||
|
"""Base handler for Pebble based containers."""
|
||||||
|
|
||||||
|
_state = ops.framework.StoredState()
|
||||||
|
|
||||||
|
def __init__(self, charm: ops.charm.CharmBase,
|
||||||
|
container_name: str, service_name: str,
|
||||||
|
container_configs: List[sunbeam_core.ContainerConfigFile],
|
||||||
|
template_dir: str, openstack_release: str,
|
||||||
|
callback_f: Callable):
|
||||||
|
super().__init__(charm, None)
|
||||||
|
self._state.set_default(pebble_ready=False)
|
||||||
|
self._state.set_default(config_pushed=False)
|
||||||
|
self._state.set_default(service_ready=False)
|
||||||
|
self.charm = charm
|
||||||
|
self.container_name = container_name
|
||||||
|
self.service_name = service_name
|
||||||
|
self.container_configs = container_configs
|
||||||
|
self.container_configs.extend(self.default_container_configs())
|
||||||
|
self.template_dir = template_dir
|
||||||
|
self.openstack_release = openstack_release
|
||||||
|
self.callback_f = callback_f
|
||||||
|
self.setup_pebble_handler()
|
||||||
|
|
||||||
|
def setup_pebble_handler(self) -> None:
|
||||||
|
"""Configure handler for pebble ready event."""
|
||||||
|
prefix = self.container_name.replace('-', '_')
|
||||||
|
pebble_ready_event = getattr(
|
||||||
|
self.charm.on,
|
||||||
|
f'{prefix}_pebble_ready')
|
||||||
|
self.framework.observe(pebble_ready_event,
|
||||||
|
self._on_service_pebble_ready)
|
||||||
|
|
||||||
|
def _on_service_pebble_ready(self,
|
||||||
|
event: ops.charm.PebbleReadyEvent) -> None:
|
||||||
|
"""Handle pebble ready event."""
|
||||||
|
container = event.workload
|
||||||
|
container.add_layer(
|
||||||
|
self.service_name,
|
||||||
|
self.get_layer(),
|
||||||
|
combine=True)
|
||||||
|
logger.debug(f'Plan: {container.get_plan()}')
|
||||||
|
self.ready = True
|
||||||
|
self._state.pebble_ready = True
|
||||||
|
self.charm.configure_charm(event)
|
||||||
|
|
||||||
|
def write_config(self, context) -> None:
|
||||||
|
"""Write configuration files into the container.
|
||||||
|
|
||||||
|
On the pre-condition that all relation adapters are ready
|
||||||
|
for use, write all configuration files into the container
|
||||||
|
so that the underlying service may be started.
|
||||||
|
"""
|
||||||
|
container = self.charm.unit.get_container(
|
||||||
|
self.container_name)
|
||||||
|
if container:
|
||||||
|
sunbeam_templating.sidecar_config_render(
|
||||||
|
[container],
|
||||||
|
self.container_configs,
|
||||||
|
self.template_dir,
|
||||||
|
self.openstack_release,
|
||||||
|
context)
|
||||||
|
self._state.config_pushed = True
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
'Container not ready')
|
||||||
|
|
||||||
|
def get_layer(self) -> dict:
|
||||||
|
"""Pebble configuration layer for the container"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def init_service(self, context) -> None:
|
||||||
|
"""Initialise service ready for use.
|
||||||
|
|
||||||
|
Write configuration files to the container and record
|
||||||
|
that service is ready for us.
|
||||||
|
"""
|
||||||
|
self.write_config(context)
|
||||||
|
self._state.service_ready = True
|
||||||
|
|
||||||
|
def default_container_configs(self) -> List[
|
||||||
|
sunbeam_core.ContainerConfigFile]:
|
||||||
|
"""Generate default container configurations.
|
||||||
|
|
||||||
|
These should be used by all inheriting classes and are
|
||||||
|
automatically added to the list or container configurations
|
||||||
|
provided during object instantiation.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pebble_ready(self) -> bool:
|
||||||
|
"""Determine if pebble is running and ready for use."""
|
||||||
|
return self._state.pebble_ready
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_pushed(self) -> bool:
|
||||||
|
"""Determine if configuration has been pushed to the container."""
|
||||||
|
return self._state.config_pushed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_ready(self) -> bool:
|
||||||
|
"""Determine whether the service the container provides is running."""
|
||||||
|
return self._state.service_ready
|
||||||
|
|
||||||
|
|
||||||
|
class WSGIPebbleHandler(PebbleHandler):
|
||||||
|
"""WSGI oriented handler for a Pebble managed container."""
|
||||||
|
|
||||||
|
def __init__(self, charm: ops.charm.CharmBase,
|
||||||
|
container_name: str, service_name: str,
|
||||||
|
container_configs: List[sunbeam_core.ContainerConfigFile],
|
||||||
|
template_dir: str, openstack_release: str,
|
||||||
|
callback_f: Callable,
|
||||||
|
wsgi_service_name: str):
|
||||||
|
super().__init__(charm, container_name, service_name,
|
||||||
|
container_configs, template_dir, openstack_release,
|
||||||
|
callback_f)
|
||||||
|
self.wsgi_service_name = wsgi_service_name
|
||||||
|
|
||||||
|
def start_wsgi(self) -> None:
|
||||||
|
"""Start WSGI service"""
|
||||||
|
container = self.charm.unit.get_container(self.container_name)
|
||||||
|
if not container:
|
||||||
|
logger.debug(f'{self.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 get_layer(self) -> dict:
|
||||||
|
"""Apache WSGI service pebble layer
|
||||||
|
|
||||||
|
:returns: pebble layer configuration for wsgi service
|
||||||
|
"""
|
||||||
|
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 init_service(self, context) -> None:
|
||||||
|
"""Enable and start WSGI service"""
|
||||||
|
container = self.charm.unit.get_container(self.container_name)
|
||||||
|
self.write_config(context)
|
||||||
|
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()
|
||||||
|
self._state.service_ready = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wsgi_conf(self) -> str:
|
||||||
|
return f'/etc/apache2/sites-available/wsgi-{self.service_name}.conf'
|
||||||
|
|
||||||
|
def default_container_configs(self) -> List[
|
||||||
|
sunbeam_core.ContainerConfigFile]:
|
||||||
|
return [
|
||||||
|
sunbeam_core.ContainerConfigFile(
|
||||||
|
[self.container_name],
|
||||||
|
self.wsgi_conf,
|
||||||
|
'root',
|
||||||
|
'root')]
|
@ -1,563 +1,38 @@
|
|||||||
#!/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 collections
|
||||||
import logging
|
|
||||||
|
|
||||||
import advanced_sunbeam_openstack.adapters as sunbeam_adapters
|
|
||||||
import advanced_sunbeam_openstack.templating as sunbeam_templating
|
|
||||||
import advanced_sunbeam_openstack.cprocess as sunbeam_cprocess
|
|
||||||
|
|
||||||
import charms.nginx_ingress_integrator.v0.ingress as ingress
|
|
||||||
import charms.mysql.v1.mysql as mysql
|
|
||||||
|
|
||||||
import ops.charm
|
|
||||||
import ops.framework
|
|
||||||
import ops.model
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import List, Tuple
|
|
||||||
from ops_openstack.adapters import OpenStackOperRelationAdapter
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
ContainerConfigFile = collections.namedtuple(
|
ContainerConfigFile = collections.namedtuple(
|
||||||
'ContainerConfigFile',
|
'ContainerConfigFile',
|
||||||
['container_names', 'path', 'user', 'group'])
|
['container_names', 'path', 'user', 'group'])
|
||||||
|
|
||||||
|
|
||||||
class PebbleHandler(ops.charm.Object):
|
class OPSCharmContexts():
|
||||||
"""Base handler for Pebble based containers."""
|
|
||||||
|
|
||||||
_state = ops.framework.StoredState()
|
def __init__(self, charm):
|
||||||
|
|
||||||
def __init__(self, charm: ops.charm.CharmBase,
|
|
||||||
container_name: str, service_name: str,
|
|
||||||
container_configs: List[ContainerConfigFile],
|
|
||||||
template_dir: str, openstack_release: str,
|
|
||||||
adapters: List[OpenStackOperRelationAdapter],
|
|
||||||
callback_f: Callable):
|
|
||||||
super().__init__(charm, None)
|
|
||||||
self._state.set_default(pebble_ready=False)
|
|
||||||
self._state.set_default(config_pushed=False)
|
|
||||||
self._state.set_default(service_ready=False)
|
|
||||||
self.charm = charm
|
self.charm = charm
|
||||||
self.container_name = container_name
|
self.namespaces = []
|
||||||
self.service_name = service_name
|
|
||||||
self.container_configs = container_configs
|
|
||||||
self.container_configs.extend(self.default_container_configs())
|
|
||||||
self.template_dir = template_dir
|
|
||||||
self.openstack_release = openstack_release
|
|
||||||
self.adapters = adapters
|
|
||||||
self.callback_f = callback_f
|
|
||||||
self.setup_pebble_handler()
|
|
||||||
|
|
||||||
def setup_pebble_handler(self) -> None:
|
def add_relation_handler(self, handler):
|
||||||
"""Configure handler for pebble ready event."""
|
|
||||||
prefix = self.container_name.replace('-', '_')
|
|
||||||
pebble_ready_event = getattr(
|
|
||||||
self.charm.on,
|
|
||||||
f'{prefix}_pebble_ready')
|
|
||||||
self.framework.observe(pebble_ready_event,
|
|
||||||
self._on_service_pebble_ready)
|
|
||||||
|
|
||||||
def _on_service_pebble_ready(self,
|
|
||||||
event: ops.charm.PebbleReadyEvent) -> None:
|
|
||||||
"""Handle pebble ready event."""
|
|
||||||
container = event.workload
|
|
||||||
container.add_layer(
|
|
||||||
self.service_name,
|
|
||||||
self.get_layer(),
|
|
||||||
combine=True)
|
|
||||||
logger.debug(f'Plan: {container.get_plan()}')
|
|
||||||
self.ready = True
|
|
||||||
self.charm.configure_charm(event)
|
|
||||||
self._state.pebble_ready = True
|
|
||||||
|
|
||||||
def write_config(self) -> None:
|
|
||||||
"""Write configuration files into the container.
|
|
||||||
|
|
||||||
On the pre-condition that all relation adapters are ready
|
|
||||||
for use, write all configuration files into the container
|
|
||||||
so that the underlying service may be started.
|
|
||||||
"""
|
|
||||||
for adapter in self.adapters:
|
|
||||||
if not adapter[1].ready:
|
|
||||||
logger.info("Adapter incomplete")
|
|
||||||
return
|
|
||||||
container = self.charm.unit.get_container(
|
|
||||||
self.container_name)
|
|
||||||
if container:
|
|
||||||
sunbeam_templating.sidecar_config_render(
|
|
||||||
[container],
|
|
||||||
self.container_configs,
|
|
||||||
self.template_dir,
|
|
||||||
self.openstack_release,
|
|
||||||
self.adapters)
|
|
||||||
self._state.config_pushed = True
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
'Container not ready')
|
|
||||||
|
|
||||||
def get_layer(self) -> dict:
|
|
||||||
"""Pebble configuration layer for the container"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def init_service(self) -> None:
|
|
||||||
"""Initialise service ready for use.
|
|
||||||
|
|
||||||
Write configuration files to the container and record
|
|
||||||
that service is ready for us.
|
|
||||||
"""
|
|
||||||
self.write_config()
|
|
||||||
self._state.service_ready = True
|
|
||||||
|
|
||||||
def default_container_configs(self) -> List[ContainerConfigFile]:
|
|
||||||
"""Generate default container configurations.
|
|
||||||
|
|
||||||
These should be used by all inheriting classes and are
|
|
||||||
automatically added to the list or container configurations
|
|
||||||
provided during object instantiation.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pebble_ready(self) -> bool:
|
|
||||||
"""Determine if pebble is running and ready for use."""
|
|
||||||
return self._state.pebble_ready
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config_pushed(self) -> bool:
|
|
||||||
"""Determine if configuration has been pushed to the container."""
|
|
||||||
return self._state.config_pushed
|
|
||||||
|
|
||||||
@property
|
|
||||||
def service_ready(self) -> bool:
|
|
||||||
"""Determine whether the service the container provides is running."""
|
|
||||||
return self._state.service_ready
|
|
||||||
|
|
||||||
|
|
||||||
class WSGIPebbleHandler(PebbleHandler):
|
|
||||||
"""WSGI oriented handler for a Pebble managed container."""
|
|
||||||
|
|
||||||
def __init__(self, charm: ops.charm.CharmBase,
|
|
||||||
container_name: str, service_name: str,
|
|
||||||
container_configs: List[ContainerConfigFile],
|
|
||||||
template_dir: str, openstack_release: str,
|
|
||||||
adapters: List[OpenStackOperRelationAdapter],
|
|
||||||
callback_f: Callable,
|
|
||||||
wsgi_service_name: str):
|
|
||||||
super().__init__(charm, container_name, service_name,
|
|
||||||
container_configs, template_dir, openstack_release,
|
|
||||||
adapters, callback_f)
|
|
||||||
self.wsgi_service_name = wsgi_service_name
|
|
||||||
|
|
||||||
def start_wsgi(self) -> None:
|
|
||||||
"""Start WSGI service"""
|
|
||||||
container = self.charm.unit.get_container(self.container_name)
|
|
||||||
if not container:
|
|
||||||
logger.debug(f'{self.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 get_layer(self) -> dict:
|
|
||||||
"""Apache WSGI service pebble layer
|
|
||||||
|
|
||||||
:returns: pebble layer configuration for wsgi service
|
|
||||||
"""
|
|
||||||
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 init_service(self) -> None:
|
|
||||||
"""Enable and start WSGI service"""
|
|
||||||
container = self.charm.unit.get_container(self.container_name)
|
|
||||||
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()
|
|
||||||
self._state.service_ready = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wsgi_conf(self) -> str:
|
|
||||||
return f'/etc/apache2/sites-available/wsgi-{self.service_name}.conf'
|
|
||||||
|
|
||||||
def default_container_configs(self) -> List[ContainerConfigFile]:
|
|
||||||
return [
|
|
||||||
ContainerConfigFile(
|
|
||||||
[self.container_name],
|
|
||||||
self.wsgi_conf,
|
|
||||||
'root',
|
|
||||||
'root')]
|
|
||||||
|
|
||||||
|
|
||||||
class RelationHandler(ops.charm.Object):
|
|
||||||
"""Base handler class for relations"""
|
|
||||||
|
|
||||||
def __init__(self, charm: ops.charm.CharmBase,
|
|
||||||
relation_name: str, callback_f: Callable):
|
|
||||||
super().__init__(charm, None)
|
|
||||||
self.charm = charm
|
|
||||||
self.relation_name = relation_name
|
|
||||||
self.callback_f = callback_f
|
|
||||||
self.interface = self.setup_event_handler()
|
|
||||||
|
|
||||||
def setup_event_handler(self) -> ops.charm.Object:
|
|
||||||
"""Configure event handlers for the relation.
|
|
||||||
|
|
||||||
This method must be overridden in concrete class
|
|
||||||
implementations.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def get_interface(self) -> Tuple[ops.charm.Object, str]:
|
|
||||||
"""Returns the interface that this handler encapsulates.
|
|
||||||
|
|
||||||
This is a combination of the interface object and the
|
|
||||||
name of the relation its wired into.
|
|
||||||
"""
|
|
||||||
return self.interface, self.relation_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ready(self) -> bool:
|
|
||||||
"""Determine with the relation is ready for use."""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class IngressHandler(RelationHandler):
|
|
||||||
"""Handler for Ingress relations"""
|
|
||||||
|
|
||||||
def __init__(self, charm: ops.charm.CharmBase,
|
|
||||||
relation_name: str,
|
|
||||||
service_name: str,
|
|
||||||
default_public_ingress_port: int,
|
|
||||||
callback_f: Callable):
|
|
||||||
self.default_public_ingress_port = default_public_ingress_port
|
|
||||||
self.service_name = service_name
|
|
||||||
super().__init__(charm, relation_name, callback_f)
|
|
||||||
|
|
||||||
def setup_event_handler(self) -> ops.charm.Object:
|
|
||||||
"""Configure event handlers for an Ingress relation."""
|
|
||||||
logger.debug('Setting up ingress event handler')
|
|
||||||
interface = ingress.IngressRequires(
|
|
||||||
self.charm,
|
|
||||||
self.ingress_config)
|
|
||||||
return interface
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ingress_config(self) -> dict:
|
|
||||||
"""Ingress controller configuration dictionary."""
|
|
||||||
# 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.default_public_ingress_port)
|
|
||||||
svc_hostname = self.model.config.get(
|
|
||||||
'os-public-hostname',
|
|
||||||
self.service_name)
|
|
||||||
return {
|
|
||||||
'service-hostname': svc_hostname,
|
|
||||||
'service-name': self.charm.app.name,
|
|
||||||
'service-port': port}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ready(self) -> bool:
|
|
||||||
# Nothing to wait for
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class DBHandler(RelationHandler):
|
|
||||||
"""Handler for DB relations"""
|
|
||||||
|
|
||||||
def setup_event_handler(self) -> ops.charm.Object:
|
|
||||||
"""Configure event handlers for a MySQL relation."""
|
|
||||||
logger.debug('Setting up DB event handler')
|
|
||||||
db = mysql.MySQLConsumer(
|
|
||||||
self.charm,
|
|
||||||
self.relation_name,
|
|
||||||
{"mysql": ">=8"})
|
|
||||||
_rname = self.relation_name.replace('-', '_')
|
|
||||||
db_relation_event = getattr(
|
|
||||||
self.charm.on,
|
|
||||||
f'{_rname}_relation_changed')
|
|
||||||
self.framework.observe(db_relation_event,
|
|
||||||
self._on_database_changed)
|
|
||||||
return db
|
|
||||||
|
|
||||||
def _on_database_changed(self, event) -> None:
|
|
||||||
"""Handles database change events."""
|
|
||||||
databases = self.interface.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.charm.app.name.replace('-', '_')
|
|
||||||
self.interface.new_database(name_suffix=name_suffix)
|
|
||||||
return
|
|
||||||
credentials = self.interface.credentials()
|
|
||||||
# XXX Lets not log the credentials
|
|
||||||
logger.info(f'Received credentials: {credentials}')
|
|
||||||
self.callback_f(event)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ready(self) -> bool:
|
|
||||||
"""Handler ready for use."""
|
|
||||||
try:
|
|
||||||
# Nothing to wait for
|
|
||||||
return bool(self.interface.databases())
|
|
||||||
except AttributeError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class OSBaseOperatorCharm(ops.charm.CharmBase):
|
|
||||||
"""Base charms for OpenStack operators."""
|
|
||||||
|
|
||||||
_state = ops.framework.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)
|
|
||||||
# Setup the observers for relationship events and pass the interfaces
|
|
||||||
# to the adapter classes.
|
|
||||||
self.relation_handlers = self.get_relation_handlers()
|
|
||||||
for handler in self.relation_handlers:
|
|
||||||
interface, relation_name = handler.get_interface()
|
interface, relation_name = handler.get_interface()
|
||||||
self.adapters.add_relation_adapter(
|
_ns = relation_name.replace("-", "_")
|
||||||
interface,
|
self.namespaces.append(_ns)
|
||||||
relation_name)
|
ctxt = handler.context()
|
||||||
self.pebble_handlers = self.get_pebble_handlers()
|
obj_name = ''.join([w.capitalize() for w in relation_name.split('-')])
|
||||||
self.framework.observe(self.on.config_changed,
|
obj = collections.namedtuple(obj_name, ctxt.keys())(*ctxt.values())
|
||||||
self._on_config_changed)
|
setattr(self, _ns, obj)
|
||||||
|
|
||||||
def get_relation_handlers(self) -> List[RelationHandler]:
|
def add_config_contexts(self, config_adapters):
|
||||||
"""Relation handlers for the operator."""
|
for config_adapter in config_adapters:
|
||||||
return []
|
self.add_config_context(
|
||||||
|
config_adapter,
|
||||||
|
config_adapter.namespace)
|
||||||
|
|
||||||
def get_pebble_handlers(self) -> List[PebbleHandler]:
|
def add_config_context(self, config_adapter, namespace):
|
||||||
"""Pebble handlers for the operator."""
|
self.namespaces.append(namespace)
|
||||||
return [
|
setattr(self, namespace, config_adapter)
|
||||||
PebbleHandler(
|
|
||||||
self,
|
|
||||||
self.service_name,
|
|
||||||
self.service_name,
|
|
||||||
self.container_configs,
|
|
||||||
self.template_dir,
|
|
||||||
self.openstack_release,
|
|
||||||
self.adapters,
|
|
||||||
self.configure_charm)]
|
|
||||||
|
|
||||||
def configure_charm(self, event) -> None:
|
def __iter__(self):
|
||||||
"""Configure containers when all dependencies are met.
|
|
||||||
|
|
||||||
Iterates over all Pebble handlers and writes configuration
|
|
||||||
files if the handler is ready for use.
|
|
||||||
"""
|
"""
|
||||||
for h in self.pebble_handlers:
|
Iterate over the relations presented to the charm.
|
||||||
if h.ready:
|
|
||||||
h.write_config()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def container_configs(self) -> List[ContainerConfigFile]:
|
|
||||||
"""Container configuration files for the operator."""
|
|
||||||
return []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config_adapters(self) -> List[sunbeam_adapters.CharmConfigAdapter]:
|
|
||||||
"""Configuration adapters for the operator."""
|
|
||||||
return [
|
|
||||||
sunbeam_adapters.CharmConfigAdapter(self, 'options')]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def handler_prefix(self) -> str:
|
|
||||||
"""Prefix for handlers??"""
|
|
||||||
return self.service_name.replace('-', '_')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def container_names(self):
|
|
||||||
"""Containers that form part of this service."""
|
|
||||||
return [self.service_name]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def template_dir(self) -> str:
|
|
||||||
"""Directory containing Jinja2 templates."""
|
|
||||||
return 'src/templates'
|
|
||||||
|
|
||||||
def _on_config_changed(self, event):
|
|
||||||
self.configure_charm(None)
|
|
||||||
|
|
||||||
def containers_ready(self) -> bool:
|
|
||||||
"""Determine whether all containers are ready for configuration."""
|
|
||||||
for ph in self.pebble_handlers:
|
|
||||||
if not ph.service_ready:
|
|
||||||
logger.info(f"Container incomplete: {ph.container_name}")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def relation_handlers_ready(self) -> bool:
|
|
||||||
"""Determine whether all relations are ready for use."""
|
|
||||||
for handler in self.relation_handlers:
|
|
||||||
if not handler.ready:
|
|
||||||
logger.info(f"Relation {handler.relation_name} incomplete")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class OSBaseOperatorAPICharm(OSBaseOperatorCharm):
|
|
||||||
"""Base class for OpenStack API operators"""
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def get_pebble_handlers(self) -> List[PebbleHandler]:
|
|
||||||
"""Pebble handlers for the service"""
|
|
||||||
return [
|
|
||||||
WSGIPebbleHandler(
|
|
||||||
self,
|
|
||||||
self.service_name,
|
|
||||||
self.service_name,
|
|
||||||
self.container_configs,
|
|
||||||
self.template_dir,
|
|
||||||
self.openstack_release,
|
|
||||||
self.adapters,
|
|
||||||
self.configure_charm,
|
|
||||||
f'wsgi-{self.service_name}')]
|
|
||||||
|
|
||||||
def get_relation_handlers(self) -> List[RelationHandler]:
|
|
||||||
"""Relation handlers for the service."""
|
|
||||||
self.db = DBHandler(
|
|
||||||
self,
|
|
||||||
f'{self.service_name}-db',
|
|
||||||
self.configure_charm)
|
|
||||||
self.ingress = IngressHandler(
|
|
||||||
self,
|
|
||||||
'ingress',
|
|
||||||
self.service_name,
|
|
||||||
self.default_public_ingress_port,
|
|
||||||
self.configure_charm)
|
|
||||||
return [self.db, self.ingress]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def container_configs(self) -> List[ContainerConfigFile]:
|
|
||||||
"""Container configuration files for the service."""
|
|
||||||
_cconfigs = super().container_configs
|
|
||||||
_cconfigs.extend([
|
|
||||||
ContainerConfigFile(
|
|
||||||
[self.wsgi_container_name],
|
|
||||||
self.service_conf,
|
|
||||||
self.service_user,
|
|
||||||
self.service_group)])
|
|
||||||
return _cconfigs
|
|
||||||
|
|
||||||
@property
|
|
||||||
def service_user(self) -> str:
|
|
||||||
"""Service user file and directory ownership."""
|
|
||||||
return self.service_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def service_group(self) -> str:
|
|
||||||
"""Service group file and directory ownership."""
|
|
||||||
return self.service_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def service_conf(self) -> str:
|
|
||||||
"""Service default configuration file."""
|
|
||||||
return f'/etc/{self.service_name}/{self.service_name}.conf'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config_adapters(self) -> List[sunbeam_adapters.ConfigAdapter]:
|
|
||||||
"""Generate list of configuration adapters for the charm."""
|
|
||||||
_cadapters = super().config_adapters
|
|
||||||
_cadapters.extend([
|
|
||||||
sunbeam_adapters.WSGIWorkerConfigAdapter(self, 'wsgi_config')])
|
|
||||||
return _cadapters
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wsgi_container_name(self) -> str:
|
|
||||||
"""Name of the WSGI application container."""
|
|
||||||
return self.service_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default_public_ingress_port(self) -> int:
|
|
||||||
"""Port to use for ingress access to service."""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def configure_charm(self, event) -> None:
|
|
||||||
"""Catchall handler to cconfigure charm services."""
|
|
||||||
if not self.relation_handlers_ready():
|
|
||||||
logging.debug("Aborting charm relations not ready")
|
|
||||||
return
|
|
||||||
|
|
||||||
for ph in self.pebble_handlers:
|
|
||||||
if ph.pebble_ready:
|
|
||||||
ph.init_service()
|
|
||||||
|
|
||||||
for ph in self.pebble_handlers:
|
|
||||||
if not ph.service_ready:
|
|
||||||
logging.debug("Aborting container service not ready")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.bootstrapped():
|
|
||||||
self._do_bootstrap()
|
|
||||||
|
|
||||||
self.unit.status = ops.model.ActiveStatus()
|
|
||||||
self._state.bootstrapped = True
|
|
||||||
|
|
||||||
def _do_bootstrap(self) -> None:
|
|
||||||
"""Bootstrap the service ready for operation.
|
|
||||||
|
|
||||||
This method should be overridden as part of a concrete
|
|
||||||
charm implementation
|
|
||||||
"""
|
"""
|
||||||
pass
|
for namespace in self.namespaces:
|
||||||
|
yield namespace, getattr(self, namespace)
|
||||||
def bootstrapped(self) -> bool:
|
|
||||||
"""Determine whether the service has been boostrapped."""
|
|
||||||
return self._state.bootstrapped
|
|
||||||
|
254
ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py
Normal file
254
ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Base classes for defining a charm using the Operator framework.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
import ops.charm
|
||||||
|
|
||||||
|
import charms.nginx_ingress_integrator.v0.ingress as ingress
|
||||||
|
import charms.mysql.v1.mysql as mysql
|
||||||
|
import charms.sunbeam_rabbitmq_operator.v0.amqp as sunbeam_amqp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RelationHandler(ops.charm.Object):
|
||||||
|
"""Base handler class for relations
|
||||||
|
|
||||||
|
A relation handler is used to manage a charms interaction with a relation
|
||||||
|
interface. This includes:
|
||||||
|
|
||||||
|
1) Registering handlers to process events from the interface. The last
|
||||||
|
step of these handlers is to make a callback to a specified method
|
||||||
|
within the charm `callback_f`
|
||||||
|
2) Expose a `ready` property so the charm can check a relations readyness
|
||||||
|
3) A `context` method which returns a dict which pulls together data
|
||||||
|
recieved and sent on an interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, charm: ops.charm.CharmBase,
|
||||||
|
relation_name: str, callback_f: Callable):
|
||||||
|
super().__init__(charm, None)
|
||||||
|
self.charm = charm
|
||||||
|
self.relation_name = relation_name
|
||||||
|
self.callback_f = callback_f
|
||||||
|
self.interface = self.setup_event_handler()
|
||||||
|
|
||||||
|
def setup_event_handler(self) -> ops.charm.Object:
|
||||||
|
"""Configure event handlers for the relation.
|
||||||
|
|
||||||
|
This method must be overridden in concrete class
|
||||||
|
implementations.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_interface(self) -> Tuple[ops.charm.Object, str]:
|
||||||
|
"""Returns the interface that this handler encapsulates.
|
||||||
|
|
||||||
|
This is a combination of the interface object and the
|
||||||
|
name of the relation its wired into.
|
||||||
|
"""
|
||||||
|
return self.interface, self.relation_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Determine with the relation is ready for use."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def context(self) -> dict:
|
||||||
|
"""Pull together context for rendering templates."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class IngressHandler(RelationHandler):
|
||||||
|
"""Handler for Ingress relations"""
|
||||||
|
|
||||||
|
def __init__(self, charm: ops.charm.CharmBase,
|
||||||
|
relation_name: str,
|
||||||
|
service_name: str,
|
||||||
|
default_public_ingress_port: int,
|
||||||
|
callback_f: Callable):
|
||||||
|
self.default_public_ingress_port = default_public_ingress_port
|
||||||
|
self.service_name = service_name
|
||||||
|
super().__init__(charm, relation_name, callback_f)
|
||||||
|
|
||||||
|
def setup_event_handler(self) -> ops.charm.Object:
|
||||||
|
"""Configure event handlers for an Ingress relation."""
|
||||||
|
logger.debug('Setting up ingress event handler')
|
||||||
|
interface = ingress.IngressRequires(
|
||||||
|
self.charm,
|
||||||
|
self.ingress_config)
|
||||||
|
return interface
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ingress_config(self) -> dict:
|
||||||
|
"""Ingress controller configuration dictionary."""
|
||||||
|
# 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.default_public_ingress_port)
|
||||||
|
svc_hostname = self.model.config.get(
|
||||||
|
'os-public-hostname',
|
||||||
|
self.service_name)
|
||||||
|
return {
|
||||||
|
'service-hostname': svc_hostname,
|
||||||
|
'service-name': self.charm.app.name,
|
||||||
|
'service-port': port}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
# Nothing to wait for
|
||||||
|
return True
|
||||||
|
|
||||||
|
def context(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class DBHandler(RelationHandler):
|
||||||
|
"""Handler for DB relations"""
|
||||||
|
|
||||||
|
def setup_event_handler(self) -> ops.charm.Object:
|
||||||
|
"""Configure event handlers for a MySQL relation."""
|
||||||
|
logger.debug('Setting up DB event handler')
|
||||||
|
db = mysql.MySQLConsumer(
|
||||||
|
self.charm,
|
||||||
|
self.relation_name,
|
||||||
|
{"mysql": ">=8"})
|
||||||
|
_rname = self.relation_name.replace('-', '_')
|
||||||
|
db_relation_event = getattr(
|
||||||
|
self.charm.on,
|
||||||
|
f'{_rname}_relation_changed')
|
||||||
|
self.framework.observe(db_relation_event,
|
||||||
|
self._on_database_changed)
|
||||||
|
return db
|
||||||
|
|
||||||
|
def _on_database_changed(self, event) -> None:
|
||||||
|
"""Handles database change events."""
|
||||||
|
databases = self.interface.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.charm.app.name.replace('-', '_')
|
||||||
|
self.interface.new_database(name_suffix=name_suffix)
|
||||||
|
return
|
||||||
|
credentials = self.interface.credentials()
|
||||||
|
# XXX Lets not log the credentials
|
||||||
|
logger.info(f'Received credentials: {credentials}')
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Handler ready for use."""
|
||||||
|
try:
|
||||||
|
# Nothing to wait for
|
||||||
|
return bool(self.interface.databases())
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def context(self):
|
||||||
|
try:
|
||||||
|
databases = self.interface.databases()
|
||||||
|
except AttributeError:
|
||||||
|
return {}
|
||||||
|
if not databases:
|
||||||
|
return {}
|
||||||
|
ctxt = {
|
||||||
|
'database': self.interface.databases()[0],
|
||||||
|
'database_host': self.interface.credentials().get('address'),
|
||||||
|
'database_password': self.interface.credentials().get('password'),
|
||||||
|
'database_user': self.interface.credentials().get('username'),
|
||||||
|
'database_type': 'mysql+pymysql'}
|
||||||
|
return ctxt
|
||||||
|
|
||||||
|
|
||||||
|
class AMQPHandler(RelationHandler):
|
||||||
|
|
||||||
|
DEFAULT_PORT = "5672"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
charm: ops.charm.CharmBase,
|
||||||
|
relation_name: str,
|
||||||
|
callback_f,
|
||||||
|
username: str,
|
||||||
|
vhost: int,
|
||||||
|
):
|
||||||
|
self.username = username
|
||||||
|
self.vhost = vhost
|
||||||
|
super().__init__(charm, relation_name, callback_f)
|
||||||
|
|
||||||
|
def setup_event_handler(self):
|
||||||
|
"""Configure event handlers for an AMQP relation."""
|
||||||
|
logger.debug("Setting up AMQP event handler")
|
||||||
|
amqp = sunbeam_amqp.AMQPRequires(
|
||||||
|
self.charm, self.relation_name, self.username, self.vhost
|
||||||
|
)
|
||||||
|
self.framework.observe(amqp.on.ready, self._on_amqp_ready)
|
||||||
|
return amqp
|
||||||
|
|
||||||
|
def _on_amqp_ready(self, event) -> None:
|
||||||
|
"""Handles AMQP change events."""
|
||||||
|
# Ready is only emitted when the interface considers
|
||||||
|
# that the relation is complete (indicated by a password)
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Handler ready for use."""
|
||||||
|
try:
|
||||||
|
return bool(self.interface.password)
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def context(self):
|
||||||
|
try:
|
||||||
|
hosts = self.interface.hostnames
|
||||||
|
except AttributeError:
|
||||||
|
return {}
|
||||||
|
if not hosts:
|
||||||
|
return {}
|
||||||
|
hosts = ','.join(hosts)
|
||||||
|
port = self.interface.ssl_port or self.DEFAULT_PORT
|
||||||
|
username = self.interface.username
|
||||||
|
password = self.interface.password
|
||||||
|
transport_url_hosts = ','.join([
|
||||||
|
"{}:{}@{}:{}".format(username,
|
||||||
|
password,
|
||||||
|
host_, # TODO deal with IPv6
|
||||||
|
port)
|
||||||
|
for host_ in self.interface.hostnames
|
||||||
|
])
|
||||||
|
transport_url = "rabbit://{}/{}".format(
|
||||||
|
transport_url_hosts,
|
||||||
|
self.vhost)
|
||||||
|
ctxt = {
|
||||||
|
'port': port,
|
||||||
|
'hosts': hosts,
|
||||||
|
'transport_url': transport_url}
|
||||||
|
return ctxt
|
@ -15,20 +15,26 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
from mock import patch
|
import os
|
||||||
|
import tempfile
|
||||||
|
from mock import ANY, patch
|
||||||
import unittest
|
import unittest
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.append('lib') # noqa
|
sys.path.append('lib') # noqa
|
||||||
sys.path.append('src') # noqa
|
sys.path.append('src') # noqa
|
||||||
|
|
||||||
from ops.testing import Harness
|
from ops import framework, model
|
||||||
|
|
||||||
import advanced_sunbeam_openstack.core as core
|
from ops.testing import Harness, _TestingModelBackend, _TestingPebbleClient
|
||||||
|
|
||||||
|
import advanced_sunbeam_openstack.charm as sunbeam_charm
|
||||||
|
|
||||||
CHARM_CONFIG = {
|
CHARM_CONFIG = {
|
||||||
'debug': 'true'}
|
'debug': 'true'}
|
||||||
|
|
||||||
CHARM_METADATA = '''
|
CHARM_METADATA = '''
|
||||||
name: my-service
|
name: my-service
|
||||||
version: 3
|
version: 3
|
||||||
@ -42,13 +48,45 @@ tags:
|
|||||||
|
|
||||||
subordinate: false
|
subordinate: false
|
||||||
|
|
||||||
|
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
|
||||||
|
'''
|
||||||
|
|
||||||
|
API_CHARM_METADATA = '''
|
||||||
|
name: my-service
|
||||||
|
version: 3
|
||||||
|
bases:
|
||||||
|
- name: ubuntu
|
||||||
|
channel: 20.04/stable
|
||||||
|
tags:
|
||||||
|
- openstack
|
||||||
|
- identity
|
||||||
|
- misc
|
||||||
|
|
||||||
|
subordinate: false
|
||||||
|
|
||||||
requires:
|
requires:
|
||||||
my-service-db:
|
my-service-db:
|
||||||
interface: mysql_datastore
|
interface: mysql_datastore
|
||||||
limit: 1
|
limit: 1
|
||||||
ingress:
|
ingress:
|
||||||
interface: ingress
|
interface: ingress
|
||||||
|
amqp:
|
||||||
|
interface: rabbitmq
|
||||||
|
|
||||||
peers:
|
peers:
|
||||||
peers:
|
peers:
|
||||||
@ -87,12 +125,18 @@ class CharmTestCase(unittest.TestCase):
|
|||||||
self.addCleanup(_m.stop)
|
self.addCleanup(_m.stop)
|
||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
def patch_obj(self, obj, method):
|
||||||
|
_m = patch.object(obj, method)
|
||||||
|
mock = _m.start()
|
||||||
|
self.addCleanup(_m.stop)
|
||||||
|
return mock
|
||||||
|
|
||||||
def patch_all(self):
|
def patch_all(self):
|
||||||
for method in self.patches:
|
for method in self.patches:
|
||||||
setattr(self, method, self.patch(method))
|
setattr(self, method, self.patch(method))
|
||||||
|
|
||||||
|
|
||||||
class MyCharm(core.OSBaseOperatorCharm):
|
class MyCharm(sunbeam_charm.OSBaseOperatorCharm):
|
||||||
|
|
||||||
openstack_release = 'diablo'
|
openstack_release = 'diablo'
|
||||||
service_name = 'my-service'
|
service_name = 'my-service'
|
||||||
@ -100,11 +144,13 @@ class MyCharm(core.OSBaseOperatorCharm):
|
|||||||
def __init__(self, framework):
|
def __init__(self, framework):
|
||||||
super().__init__(framework)
|
super().__init__(framework)
|
||||||
self.seen_events = []
|
self.seen_events = []
|
||||||
|
self.render_calls = []
|
||||||
|
|
||||||
def _log_event(self, event):
|
def _log_event(self, event):
|
||||||
self.seen_events.append(type(event).__name__)
|
self.seen_events.append(type(event).__name__)
|
||||||
|
|
||||||
def _on_service_pebble_ready(self, event):
|
def _on_service_pebble_ready(self, event):
|
||||||
|
super()._on_service_pebble_ready(event)
|
||||||
self._log_event(event)
|
self._log_event(event)
|
||||||
|
|
||||||
def _on_config_changed(self, event):
|
def _on_config_changed(self, event):
|
||||||
@ -114,10 +160,6 @@ class MyCharm(core.OSBaseOperatorCharm):
|
|||||||
super().configure_charm(event)
|
super().configure_charm(event)
|
||||||
self._log_event(event)
|
self._log_event(event)
|
||||||
|
|
||||||
def _configure_charm(self, event):
|
|
||||||
super()._configure_charm(event)
|
|
||||||
self._log_event(event)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def public_ingress_port(self):
|
def public_ingress_port(self):
|
||||||
return 789
|
return 789
|
||||||
@ -126,16 +168,14 @@ class MyCharm(core.OSBaseOperatorCharm):
|
|||||||
class TestOSBaseOperatorCharm(CharmTestCase):
|
class TestOSBaseOperatorCharm(CharmTestCase):
|
||||||
|
|
||||||
PATCHES = [
|
PATCHES = [
|
||||||
'sunbeam_templating'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp(core, self.PATCHES)
|
super().setUp(sunbeam_charm, self.PATCHES)
|
||||||
self.harness = Harness(
|
self.harness = Harness(
|
||||||
MyCharm,
|
MyCharm,
|
||||||
meta=CHARM_METADATA
|
meta=CHARM_METADATA
|
||||||
)
|
)
|
||||||
|
|
||||||
self.harness.update_config(CHARM_CONFIG)
|
self.harness.update_config(CHARM_CONFIG)
|
||||||
self.harness.begin()
|
self.harness.begin()
|
||||||
self.addCleanup(self.harness.cleanup)
|
self.addCleanup(self.harness.cleanup)
|
||||||
@ -145,19 +185,21 @@ class TestOSBaseOperatorCharm(CharmTestCase):
|
|||||||
# Emit the PebbleReadyEvent
|
# Emit the PebbleReadyEvent
|
||||||
self.harness.charm.on.my_service_pebble_ready.emit(container)
|
self.harness.charm.on.my_service_pebble_ready.emit(container)
|
||||||
|
|
||||||
def test_pebble_ready_handler(self):
|
@patch('advanced_sunbeam_openstack.templating.sidecar_config_render')
|
||||||
|
def test_pebble_ready_handler(self, _renderer):
|
||||||
self.assertEqual(self.harness.charm.seen_events, [])
|
self.assertEqual(self.harness.charm.seen_events, [])
|
||||||
self.set_pebble_ready()
|
self.set_pebble_ready()
|
||||||
self.assertEqual(self.harness.charm.seen_events, ['PebbleReadyEvent'])
|
self.assertEqual(self.harness.charm.seen_events, ['PebbleReadyEvent'])
|
||||||
|
|
||||||
def test_write_config(self):
|
@patch('advanced_sunbeam_openstack.templating.sidecar_config_render')
|
||||||
|
def test_write_config(self, _renderer):
|
||||||
self.set_pebble_ready()
|
self.set_pebble_ready()
|
||||||
self.sunbeam_templating.sidecar_config_render.assert_called_once_with(
|
_renderer.assert_called_once_with(
|
||||||
[self.harness.model.unit.get_container("my-service")],
|
[self.harness.model.unit.get_container("my-service")],
|
||||||
[],
|
[],
|
||||||
'src/templates',
|
'src/templates',
|
||||||
'diablo',
|
'diablo',
|
||||||
self.harness.charm.adapters)
|
ANY)
|
||||||
|
|
||||||
def test_handler_prefix(self):
|
def test_handler_prefix(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -178,8 +220,15 @@ class TestOSBaseOperatorCharm(CharmTestCase):
|
|||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
self.harness.charm.relation_handlers_ready())
|
self.harness.charm.relation_handlers_ready())
|
||||||
|
|
||||||
|
TEMPLATE_CONTENTS = """
|
||||||
|
{{ wsgi_config.wsgi_admin_script }}
|
||||||
|
{{ my_service_db.database_password }}
|
||||||
|
{{ options.debug }}
|
||||||
|
{{ amqp.transport_url }}
|
||||||
|
"""
|
||||||
|
|
||||||
class MyAPICharm(core.OSBaseOperatorAPICharm):
|
|
||||||
|
class MyAPICharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
||||||
openstack_release = 'diablo'
|
openstack_release = 'diablo'
|
||||||
service_name = 'my-service'
|
service_name = 'my-service'
|
||||||
wsgi_admin_script = '/bin/wsgi_admin'
|
wsgi_admin_script = '/bin/wsgi_admin'
|
||||||
@ -188,8 +237,19 @@ class MyAPICharm(core.OSBaseOperatorAPICharm):
|
|||||||
def __init__(self, framework):
|
def __init__(self, framework):
|
||||||
self.seen_events = []
|
self.seen_events = []
|
||||||
self.render_calls = []
|
self.render_calls = []
|
||||||
|
self._template_dir = self._setup_templates()
|
||||||
super().__init__(framework)
|
super().__init__(framework)
|
||||||
|
|
||||||
|
def _setup_templates(self):
|
||||||
|
tmpdir = tempfile.mkdtemp()
|
||||||
|
_template_dir = f'{tmpdir}/templates'
|
||||||
|
os.mkdir(_template_dir)
|
||||||
|
with open(f'{_template_dir}/my-service.conf.j2', 'w') as f:
|
||||||
|
f.write(TEMPLATE_CONTENTS)
|
||||||
|
with open(f'{_template_dir}/wsgi-my-service.conf.j2', 'w') as f:
|
||||||
|
f.write(TEMPLATE_CONTENTS)
|
||||||
|
return _template_dir
|
||||||
|
|
||||||
def _log_event(self, event):
|
def _log_event(self, event):
|
||||||
self.seen_events.append(type(event).__name__)
|
self.seen_events.append(type(event).__name__)
|
||||||
|
|
||||||
@ -204,26 +264,97 @@ class MyAPICharm(core.OSBaseOperatorAPICharm):
|
|||||||
def default_public_ingress_port(self):
|
def default_public_ingress_port(self):
|
||||||
return 789
|
return 789
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template_dir(self):
|
||||||
|
return self._template_dir
|
||||||
|
|
||||||
|
|
||||||
class TestOSBaseOperatorAPICharm(CharmTestCase):
|
class TestOSBaseOperatorAPICharm(CharmTestCase):
|
||||||
|
|
||||||
PATCHES = [
|
PATCHES = [
|
||||||
'sunbeam_templating',
|
|
||||||
'sunbeam_cprocess',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp(core, self.PATCHES)
|
container_calls = {
|
||||||
self.sunbeam_cprocess.ContainerProcessError = Exception
|
'push': {},
|
||||||
|
'pull': [],
|
||||||
|
'remove_path': []}
|
||||||
|
|
||||||
|
super().setUp(sunbeam_charm, self.PATCHES)
|
||||||
|
|
||||||
|
class _LYTestingPebbleClient(_TestingPebbleClient):
|
||||||
|
|
||||||
|
def push(
|
||||||
|
self, path, source, *,
|
||||||
|
encoding='utf-8', make_dirs=False, permissions=None,
|
||||||
|
user_id=None, user=None, group_id=None, group=None):
|
||||||
|
container_calls['push'][path] = {
|
||||||
|
'source': source,
|
||||||
|
'permissions': permissions,
|
||||||
|
'user': user,
|
||||||
|
'group': group}
|
||||||
|
|
||||||
|
def pull(self, path, *, encoding='utf-8'):
|
||||||
|
container_calls['pull'].append(path)
|
||||||
|
reader = io.StringIO("0")
|
||||||
|
return reader
|
||||||
|
|
||||||
|
def remove_path(self, path, *, recursive=False):
|
||||||
|
container_calls['remove_path'].append(path)
|
||||||
|
|
||||||
|
class _LYTestingModelBackend(_TestingModelBackend):
|
||||||
|
|
||||||
|
def get_pebble(self, socket_path: str):
|
||||||
|
client = self._pebble_clients.get(socket_path, None)
|
||||||
|
if client is None:
|
||||||
|
client = _LYTestingPebbleClient(self)
|
||||||
|
self._pebble_clients[socket_path] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
self.container_calls = container_calls
|
||||||
|
# self.sunbeam_cprocess.ContainerProcessError = Exception
|
||||||
self.harness = Harness(
|
self.harness = Harness(
|
||||||
MyAPICharm,
|
MyAPICharm,
|
||||||
meta=CHARM_METADATA
|
meta=API_CHARM_METADATA
|
||||||
)
|
)
|
||||||
|
self.harness._backend = _LYTestingModelBackend(
|
||||||
|
self.harness._unit_name, self.harness._meta)
|
||||||
|
self.harness._model = model.Model(
|
||||||
|
self.harness._meta,
|
||||||
|
self.harness._backend)
|
||||||
|
self.harness._framework = framework.Framework(
|
||||||
|
":memory:",
|
||||||
|
self.harness._charm_dir,
|
||||||
|
self.harness._meta,
|
||||||
|
self.harness._model)
|
||||||
|
# END Workaround
|
||||||
|
|
||||||
self.addCleanup(self.harness.cleanup)
|
self.addCleanup(self.harness.cleanup)
|
||||||
self.harness.update_config(CHARM_CONFIG)
|
self.harness.update_config(CHARM_CONFIG)
|
||||||
self.harness.begin()
|
self.harness.begin()
|
||||||
|
|
||||||
|
def add_base_amqp_relation(self):
|
||||||
|
rel_id = self.harness.add_relation('amqp', 'rabbitmq')
|
||||||
|
self.harness.add_relation_unit(
|
||||||
|
rel_id,
|
||||||
|
'rabbitmq/0')
|
||||||
|
self.harness.add_relation_unit(
|
||||||
|
rel_id,
|
||||||
|
'rabbitmq/0')
|
||||||
|
self.harness.update_relation_data(
|
||||||
|
rel_id,
|
||||||
|
'rabbitmq/0',
|
||||||
|
{'ingress-address': '10.0.0.13'})
|
||||||
|
return rel_id
|
||||||
|
|
||||||
|
def add_amqp_relation_credentials(self, rel_id):
|
||||||
|
self.harness.update_relation_data(
|
||||||
|
rel_id,
|
||||||
|
'rabbitmq',
|
||||||
|
{
|
||||||
|
'hostname': 'rabbithost1.local',
|
||||||
|
'password': 'rabbit.pass'})
|
||||||
|
|
||||||
def add_base_db_relation(self):
|
def add_base_db_relation(self):
|
||||||
rel_id = self.harness.add_relation('my-service-db', 'mysql')
|
rel_id = self.harness.add_relation('my-service-db', 'mysql')
|
||||||
self.harness.add_relation_unit(
|
self.harness.add_relation_unit(
|
||||||
@ -256,27 +387,34 @@ class TestOSBaseOperatorAPICharm(CharmTestCase):
|
|||||||
def test_write_config(self):
|
def test_write_config(self):
|
||||||
self.harness.set_leader()
|
self.harness.set_leader()
|
||||||
self.set_pebble_ready()
|
self.set_pebble_ready()
|
||||||
rel_id = self.add_base_db_relation()
|
db_rel_id = self.add_base_db_relation()
|
||||||
self.add_db_relation_credentials(rel_id)
|
self.add_db_relation_credentials(db_rel_id)
|
||||||
self.sunbeam_templating.sidecar_config_render.assert_called_once_with(
|
amqp_rel_id = self.add_base_amqp_relation()
|
||||||
[self.harness.model.unit.get_container("my-service")],
|
self.add_amqp_relation_credentials(amqp_rel_id)
|
||||||
[
|
expect_entries = [
|
||||||
core.ContainerConfigFile(
|
'/bin/wsgi_admin',
|
||||||
container_names=['my-service'],
|
'hardpassword',
|
||||||
path=('/etc/my-service/my-service.conf'),
|
'true',
|
||||||
user='my-service',
|
'rabbit://my-service:rabbit.pass@10.0.0.13:5672/my-service']
|
||||||
group='my-service'),
|
expect_string = '\n' + '\n'.join(expect_entries)
|
||||||
core.ContainerConfigFile(
|
self.assertEqual(
|
||||||
container_names=['my-service'],
|
self.container_calls['push']['/etc/my-service/my-service.conf'],
|
||||||
path=('/etc/apache2/sites-available/'
|
{
|
||||||
'wsgi-my-service.conf'),
|
'group': 'my-service',
|
||||||
user='root',
|
'permissions': None,
|
||||||
group='root')],
|
'source': expect_string,
|
||||||
'src/templates',
|
'user': 'my-service'})
|
||||||
'diablo',
|
self.assertEqual(
|
||||||
self.harness.charm.adapters)
|
self.container_calls['push'][
|
||||||
|
'/etc/apache2/sites-available/wsgi-my-service.conf'],
|
||||||
|
{
|
||||||
|
'group': 'root',
|
||||||
|
'permissions': None,
|
||||||
|
'source': expect_string,
|
||||||
|
'user': 'root'})
|
||||||
|
|
||||||
def test__on_database_changed(self):
|
@patch('advanced_sunbeam_openstack.templating.sidecar_config_render')
|
||||||
|
def test__on_database_changed(self, _renderer):
|
||||||
self.harness.set_leader()
|
self.harness.set_leader()
|
||||||
self.set_pebble_ready()
|
self.set_pebble_ready()
|
||||||
rel_id = self.add_base_db_relation()
|
rel_id = self.add_base_db_relation()
|
||||||
@ -287,17 +425,18 @@ class TestOSBaseOperatorAPICharm(CharmTestCase):
|
|||||||
requested_db = json.loads(rel_data['databases'])[0]
|
requested_db = json.loads(rel_data['databases'])[0]
|
||||||
self.assertRegex(requested_db, r'^db_.*my_service$')
|
self.assertRegex(requested_db, r'^db_.*my_service$')
|
||||||
|
|
||||||
def test_DBAdapter(self):
|
def test_contexts(self):
|
||||||
self.harness.set_leader()
|
self.harness.set_leader()
|
||||||
self.set_pebble_ready()
|
self.set_pebble_ready()
|
||||||
rel_id = self.add_base_db_relation()
|
rel_id = self.add_base_db_relation()
|
||||||
self.add_db_relation_credentials(rel_id)
|
self.add_db_relation_credentials(rel_id)
|
||||||
|
contexts = self.harness.charm.contexts()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.harness.charm.adapters.wsgi_config.wsgi_admin_script,
|
contexts.wsgi_config.wsgi_admin_script,
|
||||||
'/bin/wsgi_admin')
|
'/bin/wsgi_admin')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.harness.charm.adapters.my_service_db.database_password,
|
contexts.my_service_db.database_password,
|
||||||
'hardpassword')
|
'hardpassword')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.harness.charm.adapters.options.debug,
|
contexts.options.debug,
|
||||||
'true')
|
'true')
|
||||||
|
Loading…
x
Reference in New Issue
Block a user