336 lines
11 KiB
Python
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
|