sunbeam-charms/ops-sunbeam/ops_sunbeam/container_handlers.py
Liam Young 8ea8f2855a Start all services in a container
A container service layer is a dictionary of services so there
may be more than once service defintion. Account for this by
iterating of the services and starting each one in turn.

Change-Id: I4cba2e0cda156a6852e71059b0dc0cb1948ce9e6
2022-10-11 10:30:40 +00:00

425 lines
15 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 Pebble handlers.
The PebbleHandler defines the pebble layers, manages pushing
configuration to the containers and managing the service running
in the container.
"""
import collections
import logging
import ops_sunbeam.core as sunbeam_core
import ops_sunbeam.templating as sunbeam_templating
import ops.charm
import ops.pebble
from collections.abc import Callable
from typing import List, TypedDict
logger = logging.getLogger(__name__)
ContainerDir = collections.namedtuple(
"ContainerDir", ["path", "user", "group"]
)
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,
) -> None:
"""Run constructor."""
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()
# The structure of status variable and corresponding logic
# will change with compund status feature
self.status = ""
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: sunbeam_core.OPSCharmContexts) -> 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:
for config in self.container_configs:
sunbeam_templating.sidecar_config_render(
container,
config,
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 get_healthcheck_layer(self) -> dict:
"""Pebble configuration for health check layer for the container."""
return {}
@property
def directories(self) -> List[ContainerDir]:
"""List of directories to create in container."""
return []
def setup_dirs(self) -> None:
"""Create directories in container."""
if self.directories:
container = self.charm.unit.get_container(self.container_name)
for d in self.directories:
logging.debug(f"Creating {d.path}")
container.make_dir(
d.path,
user=d.user,
group=d.group,
make_parents=True)
def init_service(self, context: sunbeam_core.OPSCharmContexts) -> None:
"""Initialise service ready for use.
Write configuration files to the container and record
that service is ready for us.
"""
self.setup_dirs()
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
def execute(self, cmd: List, exception_on_error: bool = False,
**kwargs: TypedDict) -> str:
"""Execute given command in container managed by this handler.
:param cmd: command to execute, specified as a list of strings
:param exception_on_error: determines whether or not to raise
an exception if the command fails. By default, this method
will not raise an exception if the command fails. If it is
raised, this will rase an ops.pebble.ExecError.
:param kwargs: arguments to pass into the ops.model.Container's
execute command.
"""
container = self.charm.unit.get_container(self.container_name)
process = container.exec(cmd, **kwargs)
try:
stdout, _ = process.wait_output()
# Not logging the command in case it included a password,
# too cautious ?
logger.debug('Command complete')
if stdout:
for line in stdout.splitlines():
logger.debug(' %s', line)
return stdout
except ops.pebble.ExecError as e:
logger.error('Exited with code %d. Stderr:', e.exit_code)
for line in e.stderr.splitlines():
logger.error(' %s', line)
if exception_on_error:
raise
def add_healthchecks(self) -> None:
"""Add healthcheck layer to the plan."""
healthcheck_layer = self.get_healthcheck_layer()
if not healthcheck_layer:
logger.debug("Healthcheck layer not defined in pebble handler")
return
container = self.charm.unit.get_container(self.container_name)
try:
plan = container.get_plan()
if not plan.checks:
logger.debug("Adding healthcheck layer to the plan")
container.add_layer(
"healthchecks", healthcheck_layer, combine=True)
except ops.pebble.ConnectionError as connect_error:
logger.error("Not able to add Healthcheck layer")
logger.exception(connect_error)
def assess_status(self) -> str:
"""Assess Healthcheck status.
:return: status message based on healthchecks
:rtype: str
"""
failed_checks = []
container = self.charm.unit.get_container(self.container_name)
try:
checks = container.get_checks(level=ops.pebble.CheckLevel.READY)
for name, check in checks.items():
if check.status != ops.pebble.CheckStatus.UP:
failed_checks.append(name)
# Verify alive checks if ready checks are missing
if not checks:
checks = container.get_checks(
level=ops.pebble.CheckLevel.ALIVE)
for name, check in checks.items():
if check.status != ops.pebble.CheckStatus.UP:
failed_checks.append(name)
except ops.model.ModelError:
logger.warning(
f'Health check online for {self.container_name} not defined')
except ops.pebble.ConnectionError as connect_error:
logger.exception(connect_error)
failed_checks.append("Pebble Connection Error")
if failed_checks:
self.status = (
f'Health check failed for {self.container_name}: '
f'{failed_checks}'
)
else:
self.status = ''
def _start_all(self) -> None:
"""Start services in container."""
container = self.charm.unit.get_container(self.container_name)
services = container.get_services()
for service_name, service in services.items():
if service.is_running():
container.stop(service_name)
container.start(service_name)
class ServicePebbleHandler(PebbleHandler):
"""Container handler for containers which manage a service."""
def init_service(self, context: sunbeam_core.OPSCharmContexts) -> None:
"""Initialise service ready for use.
Write configuration files to the container and record
that service is ready for us.
"""
self.setup_dirs()
self.write_config(context)
self.start_service()
self._state.service_ready = True
def start_service(self) -> None:
"""Check and start services in container."""
container = self.charm.unit.get_container(self.container_name)
if not container:
logger.debug(f'{self.container_name} container is not ready. '
'Cannot start service.')
return
if self.service_name not in container.get_services().keys():
container.add_layer(
self.service_name,
self.get_layer(),
combine=True)
self._start_all()
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,
) -> None:
"""Run constructor."""
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:
"""Check and start services in container."""
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
if self.wsgi_service_name not in container.get_services().keys():
container.add_layer(
self.service_name,
self.get_layer(),
combine=True)
self._start_all()
def start_service(self) -> None:
"""Start the service."""
self.start_wsgi()
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 get_healthcheck_layer(self) -> dict:
"""Apache WSGI health check pebble layer.
:returns: pebble health check layer configuration for wsgi service
"""
return {
"checks": {
"up": {
"override": "replace",
"level": "alive",
"period": "10s",
"timeout": "3s",
"threshold": 3,
"exec": {
"command": "service apache2 status"
}
},
"online": {
"override": "replace",
"level": "ready",
"http": {
"url": self.charm.healthcheck_http_url
}
},
}
}
def init_service(self, context: sunbeam_core.OPSCharmContexts) -> None:
"""Enable and start WSGI service."""
container = self.charm.unit.get_container(self.container_name)
self.write_config(context)
try:
process = container.exec(
['a2ensite', self.wsgi_service_name],
timeout=5*60)
out, warnings = process.wait_output()
if warnings:
for line in warnings.splitlines():
logger.warning('a2ensite warn: %s', line.strip())
logging.debug(f'Output from a2ensite: \n{out}')
except ops.pebble.ExecError:
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:
"""Location of WSGI config file."""
return f"/etc/apache2/sites-available/wsgi-{self.service_name}.conf"
def default_container_configs(
self,
) -> List[sunbeam_core.ContainerConfigFile]:
"""Container configs for WSGI service."""
return [
sunbeam_core.ContainerConfigFile(
self.wsgi_conf, "root", "root"
)
]