2021-09-27 12:36:57 +01:00

336 lines
11 KiB
Python

#!/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