sunbeam-charms/ops-sunbeam/ops_sunbeam/charm.py
James Page ffb6177be5 Use new rabbitmq-k8s operator and interfaces
Use renamed rabbitmq-k8s operator in bundles and the new rabbitmq
interface from the same operator in the codebase.

This will require an update to fetch-libs.sh for all charms
to rollout.

Change-Id: I53c8b9d1194895bdf6322cf8a256f32459aa76d7
2022-10-13 10:06:19 +01:00

674 lines
24 KiB
Python

# 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 ops_sunbeam.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
ops_sunbeam.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
ops_sunbeam.container_handlers.PebbleHandler. The PebbleHandler
defines the pebble layers, manages pushing configuration to the
containers and managing the service running in the container.
"""
import ipaddress
import logging
from typing import List, Mapping
import ops.charm
import ops.framework
import ops.model
import ops.pebble
from lightkube import Client
from lightkube.resources.core_v1 import Service
import ops_sunbeam.config_contexts as sunbeam_config_contexts
import ops_sunbeam.container_handlers as sunbeam_chandlers
import ops_sunbeam.core as sunbeam_core
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
import charms.observability_libs.v0.kubernetes_service_patch as kube_svc_patch
logger = logging.getLogger(__name__)
class OSBaseOperatorCharm(ops.charm.CharmBase):
"""Base charms for OpenStack operators."""
_state = ops.framework.StoredState()
# Holds set of mandatory relations
mandatory_relations = set()
def __init__(self, framework: ops.framework.Framework) -> None:
"""Run constructor."""
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)
# TODO: change update_status based on compound_status feature
self.framework.observe(self.on.update_status, self._on_update_status)
def can_add_handler(
self,
relation_name: str,
handlers: List[sunbeam_rhandlers.RelationHandler],
) -> bool:
"""Whether a handler for the given relation can be added."""
if relation_name not in self.meta.relations.keys():
logging.debug(
f"Cannot add handler for relation {relation_name}, relation "
"not present in charm metadata"
)
return False
if relation_name in [h.relation_name for h in handlers]:
logging.debug(
f"Cannot add handler for relation {relation_name}, handler "
"already present"
)
return False
return True
def get_relation_handlers(
self, handlers: List[sunbeam_rhandlers.RelationHandler] = None
) -> List[sunbeam_rhandlers.RelationHandler]:
"""Relation handlers for the service."""
handlers = handlers or []
if self.can_add_handler("amqp", handlers):
self.amqp = sunbeam_rhandlers.RabbitMQHandler(
self,
"amqp",
self.configure_charm,
self.config.get("rabbit-user") or self.service_name,
self.config.get("rabbit-vhost") or "openstack",
"amqp" in self.mandatory_relations,
)
handlers.append(self.amqp)
self.dbs = {}
for relation_name, database_name in self.databases.items():
if self.can_add_handler(relation_name, handlers):
db = sunbeam_rhandlers.DBHandler(
self,
relation_name,
self.configure_charm,
database_name,
relation_name in self.mandatory_relations,
)
self.dbs[relation_name] = db
handlers.append(db)
if self.can_add_handler("peers", handlers):
self.peers = sunbeam_rhandlers.BasePeerHandler(
self, "peers", self.configure_charm, False
)
handlers.append(self.peers)
if self.can_add_handler("certificates", handlers):
self.certs = sunbeam_rhandlers.CertificatesHandler(
self,
"certificates",
self.configure_charm,
self.get_sans(),
"certificates" in self.mandatory_relations,
)
handlers.append(self.certs)
if self.can_add_handler("cloud-credentials", handlers):
self.ccreds = sunbeam_rhandlers.CloudCredentialsRequiresHandler(
self,
'cloud-credentials',
self.configure_charm,
'cloud-credentials' in self.mandatory_relations,
)
handlers.append(self.ccreds)
return handlers
def get_sans(self) -> List[str]:
"""Return Subject Alternate Names to use in cert for service."""
str_ips_sans = [str(s) for s in self.get_ip_sans()]
return list(set(str_ips_sans + self.get_domain_name_sans()))
def get_ip_sans(self) -> List[ipaddress.IPv4Address]:
"""Get IP addresses for service."""
ip_sans = []
for relation_name in self.meta.relations.keys():
for relation in self.framework.model.relations.get(
relation_name, []):
binding = self.model.get_binding(relation)
ip_sans.append(binding.network.ingress_address)
ip_sans.append(binding.network.bind_address)
for binding_name in ['public']:
try:
binding = self.model.get_binding(binding_name)
ip_sans.append(binding.network.ingress_address)
ip_sans.append(binding.network.bind_address)
except ops.model.ModelError:
logging.debug(f'No binding found for {binding_name}')
return ip_sans
def get_domain_name_sans(self) -> List[str]:
"""Get Domain names for service."""
domain_name_sans = []
for binding_config in ['admin', 'internal', 'public']:
hostname = self.config.get(f'os-{binding_config}-hostname')
if hostname:
domain_name_sans.append(hostname)
return domain_name_sans
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 get_named_pebble_handler(
self,
container_name: str
) -> sunbeam_chandlers.PebbleHandler:
"""Get pebble handler matching container_name."""
pebble_handlers = [
h
for h in self.pebble_handlers
if h.container_name == container_name
]
assert len(pebble_handlers) < 2, ("Multiple pebble handlers with the "
"same name found.")
if pebble_handlers:
return pebble_handlers[0]
else:
return None
def get_named_pebble_handlers(
self,
container_names: List[str]
) -> List[sunbeam_chandlers.PebbleHandler]:
"""Get pebble handlers matching container_names."""
return [
h
for h in self.pebble_handlers
if h.container_name in container_names
]
def configure_charm(self, event: ops.framework.EventBase) -> None:
"""Catchall handler to configure charm services."""
if self.supports_peer_relation and not (
self.unit.is_leader() or self.is_leader_ready()
):
logging.debug("Leader not ready")
return
if not self.relation_handlers_ready():
logging.debug("Aborting charm relations not ready")
return
for ph in self.pebble_handlers:
if ph.pebble_ready:
logging.debug(f"Running init for {ph.service_name}")
ph.init_service(self.contexts())
else:
logging.debug(
f"Not running init for {ph.service_name},"
" container not ready")
for ph in self.pebble_handlers:
if not ph.service_ready:
logging.debug(
f"Aborting container {ph.service_name} service not ready")
return
if not self.bootstrapped():
if not self._do_bootstrap():
self._state.bootstrapped = False
logging.warning(
"Failed to bootstrap the service, event deferred")
# Defer the event to re-trigger the bootstrap process
event.defer()
return
if self.unit.is_leader() and self.supports_peer_relation:
self.set_leader_ready()
# Add healthchecks to the plan
for ph in self.pebble_handlers:
ph.add_healthchecks()
self.unit.status = ops.model.ActiveStatus()
self._state.bootstrapped = True
@property
def supports_peer_relation(self) -> bool:
"""Whether the charm support the peers relation."""
return "peers" in self.meta.relations.keys()
@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]:
"""Return the configuration adapters for the operator."""
return [sunbeam_config_contexts.CharmConfigContext(self, "options")]
@property
def _unused_handler_prefix(self) -> str:
"""Prefix for handlers."""
return self.service_name.replace("-", "_")
@property
def container_names(self) -> List[str]:
"""Names of Containers that form part of this service."""
return [self.service_name]
@property
def template_dir(self) -> str:
"""Directory containing Jinja2 templates."""
return "src/templates"
@property
def databases(self) -> Mapping[str, str]:
"""
Return a mapping of database relation names to database names.
Use this to define the databases required by an application.
All entries here
that have a corresponding relation defined in metadata
will automatically have a a DBHandler instance set up for it,
and assigned to `charm.dbs[relation_name]`.
Entries that don't have a matching relation in metadata
will be ignored.
Note that the relation interface type is expected to be 'mysql_client'.
It defaults to loading a relation named "database",
with the database named after the service name.
"""
return {
"database": self.service_name.replace("-", "_")
}
def _on_config_changed(self, event: ops.framework.EventBase) -> None:
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."""
ready_relations = {
handler.relation_name
for handler in self.relation_handlers
if handler.mandatory and handler.ready
}
not_ready_relations = self.mandatory_relations.difference(
ready_relations)
if len(not_ready_relations) != 0:
logger.info(f"Relations {not_ready_relations} 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 bootstrapped(self) -> bool:
"""Determine whether the service has been boostrapped."""
return self._state.bootstrapped
def leader_set(self, settings: dict = None, **kwargs) -> None:
"""Juju set data in peer data bag."""
settings = settings or {}
settings.update(kwargs)
self.peers.set_app_data(settings=settings)
def leader_get(self, key: str) -> str:
"""Retrieve data from the peer relation."""
return self.peers.get_app_data(key)
def set_leader_ready(self) -> None:
"""Tell peers that the leader is ready."""
self.peers.set_leader_ready()
def is_leader_ready(self) -> bool:
"""Has the lead unit announced that it is ready."""
return self.peers.is_leader_ready()
@property
def db_sync_container_name(self) -> str:
"""Name of Containerto run db sync from."""
return self.service_name
def run_db_sync(self) -> None:
"""Run DB sync to init DB.
:raises: pebble.ExecError
"""
try:
if self.db_sync_cmds:
logger.info("Syncing database...")
container = self.unit.get_container(
self.db_sync_container_name)
for cmd in self.db_sync_cmds:
logging.debug('Running sync: \n%s', cmd)
process = container.exec(cmd, timeout=5*60)
out, warnings = process.wait_output()
if warnings:
for line in warnings.splitlines():
logger.warning('DB Sync Out: %s', line.strip())
logging.debug('Output from database sync: \n%s', out)
except AttributeError:
logger.warning(
"Not DB sync ran. Charm does not specify self.db_sync_cmds")
def _do_bootstrap(self) -> bool:
"""Perform bootstrap.
:return: Return True if bootstrap is success
:rtype: bool
"""
try:
self.run_db_sync()
return True
except ops.pebble.ExecError as e:
logger.exception('Failed to bootstrap')
logger.error('Exited with code %d. Stderr:', e.exit_code)
for line in e.stderr.splitlines():
logger.error(' %s', line)
return False
def _on_update_status(self, event: ops.framework.EventBase) -> None:
"""Update status event handler."""
status = []
for ph in self.pebble_handlers:
ph.assess_status()
# Below lines are not required with compound status feature
if ph.status:
status.append(ph.status)
# Need to be changed once compound status in place
if len(status) == 0:
self.unit.status = ops.model.ActiveStatus()
else:
status_msg = ','.join(status)
self.unit.status = ops.model.BlockedStatus(status_msg)
class OSBaseOperatorAPICharm(OSBaseOperatorCharm):
"""Base class for OpenStack API operators."""
mandatory_relations = {
'database',
'identity-service',
'ingress-public'
}
def __init__(self, framework: ops.framework.Framework) -> None:
"""Run constructor."""
super().__init__(framework)
self._state.set_default(db_ready=False)
self.service_patcher = kube_svc_patch.KubernetesServicePatch(
self,
ports=[(f"{self.app.name}", self.default_public_ingress_port)],
)
@property
def service_endpoints(self) -> List[dict]:
"""List of endpoints for this service."""
return []
def get_relation_handlers(
self, handlers: List[sunbeam_rhandlers.RelationHandler] = None
) -> List[sunbeam_rhandlers.RelationHandler]:
"""Relation handlers for the service."""
handlers = handlers or []
# Note: intentionally including the ingress handler here in order to
# be able to link the ingress and identity-service handlers.
if self.can_add_handler("ingress-internal", handlers):
self.ingress_internal = sunbeam_rhandlers.IngressInternalHandler(
self,
"ingress-internal",
self.service_name,
self.default_public_ingress_port,
self._ingress_changed,
"ingress-internal" in self.mandatory_relations,
)
handlers.append(self.ingress_internal)
if self.can_add_handler("ingress-public", handlers):
self.ingress_public = sunbeam_rhandlers.IngressPublicHandler(
self,
"ingress-public",
self.service_name,
self.default_public_ingress_port,
self._ingress_changed,
"ingress-public" in self.mandatory_relations,
)
handlers.append(self.ingress_public)
if self.can_add_handler("identity-service", handlers):
self.id_svc = sunbeam_rhandlers.IdentityServiceRequiresHandler(
self,
"identity-service",
self.configure_charm,
self.service_endpoints,
self.model.config["region"],
"identity-service" in self.mandatory_relations,
)
handlers.append(self.id_svc)
return super().get_relation_handlers(handlers)
def _ingress_changed(self, event: ops.framework.EventBase) -> None:
"""Ingress changed callback.
Invoked when the data on the ingress relation has changed. This will
update the relevant endpoints with the identity service, and then
call the configure_charm.
"""
logger.debug('Received an ingress_changed event')
try:
if self.id_svc.update_service_endpoints:
logger.debug('Updating service endpoints after ingress '
'relation changed.')
self.id_svc.update_service_endpoints(self.service_endpoints)
except AttributeError:
pass
self.configure_charm(event)
def service_url(self, hostname: str) -> str:
"""Service url for accessing this service via the given hostname."""
return f"http://{hostname}:{self.default_public_ingress_port}"
@property
def public_ingress_address(self) -> str:
"""IP address or hostname for access to this service."""
svc_hostname = self.model.config.get("os-public-hostname")
if svc_hostname:
return svc_hostname
client = Client()
charm_service = client.get(
Service, name=self.app.name, namespace=self.model.name
)
status = charm_service.status
if status:
load_balancer_status = status.loadBalancer
if load_balancer_status:
ingress_addresses = load_balancer_status.ingress
if ingress_addresses:
logger.debug('Found ingress addresses on loadbalancer '
'status')
ingress_address = ingress_addresses[0]
addr = ingress_address.hostname or ingress_address.ip
if addr:
logger.debug('Using ingress address from loadbalancer '
f'as {addr}')
return ingress_address.hostname or ingress_address.ip
hostname = self.model.get_binding(
'identity-service'
).network.ingress_address
return hostname
@property
def public_url(self) -> str:
"""Url for accessing the public endpoint for this service."""
try:
if self.ingress_public.url:
logger.debug('Ingress-public relation found, returning '
'ingress-public.url of: %s',
self.ingress_public.url)
return self.ingress_public.url
except AttributeError:
pass
return self.service_url(self.public_ingress_address)
@property
def admin_url(self) -> str:
"""Url for accessing the admin endpoint for this service."""
hostname = self.model.get_binding(
"identity-service"
).network.ingress_address
return self.service_url(hostname)
@property
def internal_url(self) -> str:
"""Url for accessing the internal endpoint for this service."""
try:
if self.ingress_internal.url:
logger.debug('Ingress-internal relation found, returning '
'ingress_internal.url of: %s',
self.ingress_internal.url)
return self.ingress_internal.url
except AttributeError:
pass
hostname = self.model.get_binding(
"identity-service"
).network.ingress_address
return self.service_url(hostname)
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}",
)
]
@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.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
@property
def db_sync_container_name(self) -> str:
"""Name of Containerto run db sync from."""
return self.wsgi_container_name
@property
def healthcheck_http_url(self) -> str:
"""Healthcheck HTTP URL for the service."""
return f'http://localhost:{self.default_public_ingress_port}/'