sunbeam-charms/ops-sunbeam/ops_sunbeam/charm.py
Guillaume Boutry 5210d9e230
[ops-sunbeam] ingress_changed can fail when the relation is gone
Catch AttributeError/KeyError as a stop gap to prevent _ingress_changed
to fail on relation-broken.

Change-Id: Ib29af68bf498c6adb84064f871dc678b783213c3
Signed-off-by: Guillaume Boutry <guillaume.boutry@canonical.com>
2024-08-21 09:13:46 +02:00

1072 lines
40 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
import urllib
import urllib.parse
from typing import (
List,
Mapping,
Optional,
Sequence,
Set,
)
import ops.charm
import ops.framework
import ops.model
import ops.pebble
import ops.storage
import ops_sunbeam.compound_status as compound_status
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.guard as sunbeam_guard
import ops_sunbeam.job_ctrl as sunbeam_job_ctrl
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
import tenacity
from lightkube.core.client import (
Client,
)
from lightkube.resources.core_v1 import (
Service,
)
from ops.charm import (
SecretChangedEvent,
SecretRemoveEvent,
SecretRotateEvent,
)
from ops.model import (
ActiveStatus,
MaintenanceStatus,
)
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[str] = set()
service_name: str
def __init__(self, framework: ops.framework.Framework) -> None:
"""Run constructor."""
super().__init__(framework)
if isinstance(self.framework._storage, ops.storage.JujuStorage):
raise ValueError(
(
"use_juju_for_storage=True is deprecated and not supported "
"by ops_sunbeam"
)
)
# unit_bootstrapped is stored in the local unit storage which is lost
# when the pod is replaced, so this will revert to False on charm
# upgrade or upgrade of the payload container.
self._state.set_default(unit_bootstrapped=False)
self.status = compound_status.Status("workload", priority=100)
self.status_pool = compound_status.StatusPool(self)
self.status_pool.add(self.status)
self.relation_handlers = self.get_relation_handlers()
self.bootstrap_status = compound_status.Status(
"bootstrap", priority=90
)
self.status_pool.add(self.bootstrap_status)
if not self.bootstrapped():
self.bootstrap_status.set(
MaintenanceStatus("Service not bootstrapped")
)
self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.secret_changed, self._on_secret_changed)
self.framework.observe(self.on.secret_rotate, self._on_secret_rotate)
self.framework.observe(self.on.secret_remove, self._on_secret_remove)
self.framework.observe(
self.on.collect_unit_status, self._on_collect_unit_status_event
)
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 = None
) -> list[sunbeam_rhandlers.RelationHandler]:
"""Relation handlers for the service."""
handlers = handlers or []
if self.can_add_handler("tracing", handlers):
self.tracing = sunbeam_rhandlers.TracingRequireHandler(
self, "tracing", "tracing" in self.mandatory_relations
)
if self.can_add_handler("amqp", handlers):
self.amqp = sunbeam_rhandlers.RabbitMQHandler(
self,
"amqp",
self.configure_charm,
str(self.config.get("rabbit-user") or self.service_name),
str(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.TlsCertificatesHandler(
self,
"certificates",
self.configure_charm,
sans_dns=self.get_sans_dns(),
sans_ips=self.get_sans_ips(),
mandatory="certificates" in self.mandatory_relations,
)
handlers.append(self.certs)
if self.can_add_handler("identity-credentials", handlers):
self.ccreds = sunbeam_rhandlers.IdentityCredentialsRequiresHandler(
self,
"identity-credentials",
self.configure_charm,
"identity-credentials" in self.mandatory_relations,
)
handlers.append(self.ccreds)
if self.can_add_handler("ceph-access", handlers):
self.ceph_access = sunbeam_rhandlers.CephAccessRequiresHandler(
self,
"ceph-access",
self.configure_charm,
"ceph-access" in self.mandatory_relations,
)
handlers.append(self.ceph_access)
if self.can_add_handler("receive-ca-cert", handlers):
self.receive_ca_cert = (
sunbeam_rhandlers.CertificateTransferRequiresHandler(
self, "receive-ca-cert", self.configure_charm
)
)
handlers.append(self.receive_ca_cert)
return handlers
def get_tracing_endpoint(self) -> str | None:
"""Get the tracing endpoint for the service."""
if hasattr(self, "tracing"):
return self.tracing.tracing_endpoint()
return None
def get_sans_ips(self) -> List[str]:
"""Return Subject Alternate Names to use in cert for service."""
str_ips_sans = [str(s) for s in self._ip_sans()]
return list(set(str_ips_sans))
def get_sans_dns(self) -> List[str]:
"""Return Subject Alternate Names to use in cert for service."""
return list(set(self.get_domain_name_sans()))
def _get_all_relation_addresses(self) -> list[ipaddress.IPv4Address]:
"""Return all bind/ingress addresses from all relations."""
addresses = []
for relation_name in self.meta.relations.keys():
for relation in self.framework.model.relations.get(
relation_name, []
):
binding = self.model.get_binding(relation)
if binding is None or binding.network is None:
continue
if isinstance(
binding.network.ingress_address, ipaddress.IPv4Address
):
addresses.append(binding.network.ingress_address)
if isinstance(
binding.network.bind_address, ipaddress.IPv4Address
):
addresses.append(binding.network.bind_address)
return addresses
def _ip_sans(self) -> list[ipaddress.IPv4Address]:
"""Get IPv4 addresses for service."""
ip_sans = self._get_all_relation_addresses()
for binding_name in ["public"]:
try:
binding = self.model.get_binding(binding_name)
if binding is None or binding.network is None:
continue
if isinstance(
binding.network.ingress_address, ipaddress.IPv4Address
):
ip_sans.append(binding.network.ingress_address)
if isinstance(
binding.network.bind_address, ipaddress.IPv4Address
):
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."""
return []
def check_leader_ready(self):
"""Check the leader is reporting as ready."""
if self.supports_peer_relation and not (
self.unit.is_leader() or self.is_leader_ready()
):
raise sunbeam_guard.WaitingExceptionError("Leader not ready")
def check_relation_handlers_ready(self, event: ops.framework.EventBase):
"""Check all relation handlers are ready."""
not_ready_relations = self.get_mandatory_relations_not_ready(event)
if not_ready_relations:
logger.info(f"Relations {not_ready_relations} incomplete")
self.stop_services(not_ready_relations)
raise sunbeam_guard.WaitingExceptionError(
"Not all relations are ready"
)
def update_relations(self):
"""Update relation data."""
for handler in self.relation_handlers:
try:
handler.update_relation_data()
except NotImplementedError:
logging.debug(f"send_requests not implemented for {handler}")
def configure_unit(self, event: ops.framework.EventBase) -> None:
"""Run configuration on this unit."""
self.check_leader_ready()
self.check_relation_handlers_ready(event)
self._state.unit_bootstrapped = True
def configure_app_leader(self, event):
"""Run global app setup.
These are tasks that should only be run once per application and only
the leader runs them.
"""
self.set_leader_ready()
def configure_app_non_leader(self, event):
"""Setup steps for a non-leader after leader has bootstrapped."""
if not self.bootstrapped():
raise sunbeam_guard.WaitingExceptionError("Leader not ready")
def configure_app(self, event):
"""Check on (and run if leader) app wide tasks."""
if self.unit.is_leader():
self.configure_app_leader(event)
else:
self.configure_app_non_leader(event)
def post_config_setup(self):
"""Configuration steps after services have been setup."""
logger.info("Setting active status")
self.status.set(ActiveStatus(""))
def configure_charm(self, event: ops.framework.EventBase) -> None:
"""Catchall handler to configure charm services."""
with sunbeam_guard.guard(self, "Bootstrapping"):
# Publishing relation data may be dependent on something else (like
# receiving a piece of data from the leader). To cover that
# republish relation if the relation adapter has implemented an
# update method.
self.update_relations()
self.configure_unit(event)
self.configure_app(event)
self.bootstrap_status.set(ActiveStatus())
self.post_config_setup()
def stop_services(self, relation: Optional[Set[str]] = None) -> None:
"""Stop all running services."""
# Machine charms should implement this function if required.
@property
def supports_peer_relation(self) -> bool:
"""Whether the charm support the peers relation."""
return "peers" in self.meta.relations.keys()
@property
def config_contexts(
self,
) -> list[sunbeam_config_contexts.ConfigContext]:
"""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 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_collect_unit_status_event(self, event: ops.CollectStatusEvent):
"""Publish the unit status.
collect_unit_status is called at the end of the hook's execution,
making it the best place to publish an active status.
"""
status = self.status_pool.compute_status()
if status:
event.add_status(status)
def _on_config_changed(self, event: ops.framework.EventBase) -> None:
self.configure_charm(event)
def _on_secret_changed(self, event: SecretChangedEvent) -> None:
# By default read the latest content of secret
# this will allow juju to trigger secret-remove
# event for old revision
event.secret.get_content(refresh=True)
self.configure_charm(event)
def _on_secret_rotate(self, event: SecretRotateEvent) -> None:
# Placeholder to handle secret rotate event
# charms should handle the event if required
pass
def _on_secret_remove(self, event: SecretRemoveEvent) -> None:
# Placeholder to handle secret remove event
# charms should handle the event if required
pass
def check_broken_relations(
self, relations: set, event: ops.framework.EventBase
) -> set:
"""Return all broken relations on given set of relations."""
broken_relations = set()
# Check for each relation if the event is gone away event.
# lazy import the events
# Note: Ceph relation not handled as there is no gone away event.
for relation in relations:
_is_broken = False
match relation:
case "database" | "api-database" | "cell-database":
from ops.charm import (
RelationBrokenEvent,
)
if isinstance(event, RelationBrokenEvent):
_is_broken = True
case "ingress-public" | "ingress-internal":
from charms.traefik_k8s.v2.ingress import (
IngressPerAppRevokedEvent,
)
if isinstance(event, IngressPerAppRevokedEvent):
_is_broken = True
case "identity-service":
from charms.keystone_k8s.v1.identity_service import (
IdentityServiceGoneAwayEvent,
)
if isinstance(event, IdentityServiceGoneAwayEvent):
_is_broken = True
case "amqp":
from charms.rabbitmq_k8s.v0.rabbitmq import (
RabbitMQGoneAwayEvent,
)
if isinstance(event, RabbitMQGoneAwayEvent):
_is_broken = True
case "certificates":
from charms.tls_certificates_interface.v3.tls_certificates import (
AllCertificatesInvalidatedEvent,
)
if isinstance(event, AllCertificatesInvalidatedEvent):
_is_broken = True
case "ovsdb-cms":
from charms.ovn_central_k8s.v0.ovsdb import (
OVSDBCMSGoneAwayEvent,
)
if isinstance(event, OVSDBCMSGoneAwayEvent):
_is_broken = True
case "identity-credentials":
from charms.keystone_k8s.v0.identity_credentials import (
IdentityCredentialsGoneAwayEvent,
)
if isinstance(event, IdentityCredentialsGoneAwayEvent):
_is_broken = True
case "identity-ops":
from charms.keystone_k8s.v0.identity_resource import (
IdentityOpsProviderGoneAwayEvent,
)
if isinstance(event, IdentityOpsProviderGoneAwayEvent):
_is_broken = True
case "gnocchi-db":
from charms.gnocchi_k8s.v0.gnocchi_service import (
GnocchiServiceGoneAwayEvent,
)
if isinstance(event, GnocchiServiceGoneAwayEvent):
_is_broken = True
case "ceph-access":
from charms.cinder_ceph_k8s.v0.ceph_access import (
CephAccessGoneAwayEvent,
)
if isinstance(event, CephAccessGoneAwayEvent):
_is_broken = True
case "dns-backend":
from charms.designate_bind_k8s.v0.bind_rndc import (
BindRndcGoneAwayEvent,
)
if isinstance(event, BindRndcGoneAwayEvent):
_is_broken = True
if _is_broken:
broken_relations.add(relation)
return broken_relations
def get_mandatory_relations_not_ready(
self, event: ops.framework.EventBase
) -> Set[str]:
"""Get mandatory relations that are not ready for use."""
ready_relations = {
handler.relation_name
for handler in self.relation_handlers
if handler.mandatory and handler.ready
}
# The relation data for broken relations are not cleared during
# processing of gone away event. This is a temporary workaround
# to mark broken relations as not ready.
# The workaround can be removed once the below bug is resolved
# https://bugs.launchpad.net/juju/+bug/2024583
# https://github.com/canonical/operator/issues/940
broken_relations = self.check_broken_relations(ready_relations, event)
ready_relations = ready_relations.difference(broken_relations)
not_ready_relations = self.mandatory_relations.difference(
ready_relations
)
return not_ready_relations
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 bootstrapped."""
return (
self._state.unit_bootstrapped # type: ignore[truthy-function] # unit_bootstrapped is not a function
and self.is_leader_ready()
)
def leader_set(
self,
settings: sunbeam_core.RelationDataMapping | None = 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 | None:
"""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."""
try:
self.peers.set_leader_ready()
except AttributeError:
logging.warning("Cannot set leader ready as peer relation missing")
def is_leader_ready(self) -> bool:
"""Has the lead unit announced that it is ready."""
leader_ready = False
try:
leader_ready = self.peers.is_leader_ready()
except AttributeError:
logging.warning(
"Cannot check leader ready as peer relation missing. "
"Assuming it is ready."
)
leader_ready = True
return leader_ready
class OSBaseOperatorCharmK8S(OSBaseOperatorCharm):
"""Base charm class for k8s based charms."""
def __init__(self, framework: ops.framework.Framework) -> None:
"""Run constructor."""
super().__init__(framework)
self.pebble_handlers = self.get_pebble_handlers()
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.configure_charm,
)
]
def get_named_pebble_handler(
self, container_name: str
) -> sunbeam_chandlers.PebbleHandler | None:
"""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: Sequence[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 init_container_services(self):
"""Run init on pebble handlers that are ready."""
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"
)
raise sunbeam_guard.WaitingExceptionError(
"Payload container not ready"
)
def check_pebble_handlers_ready(self):
"""Check pebble handlers are ready."""
for ph in self.pebble_handlers:
if not ph.service_ready:
logging.debug(
f"Aborting container {ph.service_name} service not ready"
)
raise sunbeam_guard.WaitingExceptionError(
"Container service not ready"
)
def stop_services(self, relation: set[str] | None = None) -> None:
"""Stop all running services."""
for ph in self.pebble_handlers:
if ph.pebble_ready:
logging.debug(
f"Stopping all services in container {ph.container_name}"
)
ph.stop_all()
def configure_unit(self, event: ops.EventBase) -> None:
"""Run configuration on this unit."""
self.check_leader_ready()
self.check_relation_handlers_ready(event)
self.open_ports()
self.init_container_services()
self.check_pebble_handlers_ready()
self.run_db_sync()
self._state.unit_bootstrapped = True
def get_relation_handlers(
self, handlers: list[sunbeam_rhandlers.RelationHandler] | None = None
) -> list[sunbeam_rhandlers.RelationHandler]:
"""Relation handlers for the service."""
handlers = handlers or []
if self.can_add_handler("logging", handlers):
self.logging = sunbeam_rhandlers.LogForwardHandler(
self,
"logging",
"logging" in self.mandatory_relations,
)
handlers.append(self.logging)
return super().get_relation_handlers(handlers)
def add_pebble_health_checks(self):
"""Add health checks for services in payload containers."""
for ph in self.pebble_handlers:
ph.add_healthchecks()
def post_config_setup(self):
"""Configuration steps after services have been setup."""
self.add_pebble_health_checks()
logger.info("Setting active status")
self.status.set(ActiveStatus(""))
@property
def container_configs(self) -> list[sunbeam_core.ContainerConfigFile]:
"""Container configuration files for the operator."""
return []
@property
def container_names(self) -> list[str]:
"""Names of Containers that form part of this service."""
return [self.service_name]
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
@property
def db_sync_container_name(self) -> str:
"""Name of Containerto run db sync from."""
return self.service_name
@tenacity.retry(
stop=tenacity.stop_after_attempt(10),
retry=(
tenacity.retry_if_exception_type(ops.pebble.ChangeError)
| tenacity.retry_if_exception_type(ops.pebble.ExecError)
| tenacity.retry_if_exception_type(ops.pebble.TimeoutError)
),
after=tenacity.after_log(logger, logging.WARNING),
wait=tenacity.wait_exponential(multiplier=1, min=10, max=300),
)
def _retry_db_sync(self, cmd):
container = self.unit.get_container(self.db_sync_container_name)
logging.debug("Running sync: \n%s", cmd)
try:
process = container.exec(cmd, timeout=5 * 60)
out, err = process.wait_output()
except ops.pebble.ExecError as e:
logger.warning(f"DB Sync pebble exec error: {str(e)}")
raise e
if err:
for line in err.splitlines():
logger.warning("DB Sync stderr: %s", line.strip())
if out:
for line in out.splitlines():
logger.debug("DB Sync stdout: %s", line.strip())
@sunbeam_job_ctrl.run_once_per_unit("db-sync")
def run_db_sync(self) -> None:
"""Run DB sync to init DB.
:raises: pebble.ExecError
"""
if not self.unit.is_leader():
logging.info("Not lead unit, skipping DB syncs")
return
if db_sync_cmds := getattr(self, "db_sync_cmds", None):
if db_sync_cmds:
logger.info("Syncing database...")
for cmd in db_sync_cmds:
try:
self._retry_db_sync(cmd)
except tenacity.RetryError:
raise sunbeam_guard.BlockedExceptionError(
"DB sync failed"
)
else:
logger.warning(
"Not DB sync ran. Charm does not specify self.db_sync_cmds"
)
def open_ports(self):
"""Register ports in underlying cloud."""
pass
class OSBaseOperatorAPICharm(OSBaseOperatorCharmK8S):
"""Base class for OpenStack API operators."""
mandatory_relations = {"database", "identity-service", "ingress-public"}
wsgi_admin_script: str
wsgi_public_script: str
def __init__(self, framework: ops.framework.Framework) -> None:
"""Run constructor."""
super().__init__(framework)
@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 = 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,
str(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")
if hasattr(self, "id_svc"):
logger.debug(
"Updating service endpoints after ingress relation changed."
)
try:
self.id_svc.update_service_endpoints(self.service_endpoints)
except (AttributeError, KeyError):
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."""
client = Client()
charm_service = client.get(
Service, name=self.app.name, namespace=self.model.name
)
public_address = None
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}"
)
public_address = (
ingress_address.hostname or ingress_address.ip
)
if not public_address:
binding = self.model.get_binding("identity-service")
if binding and binding.network and binding.network.ingress_address:
public_address = str(binding.network.ingress_address)
if not public_address:
raise sunbeam_guard.WaitingExceptionError(
"No public address found for service"
)
return public_address
@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.add_explicit_port(self.ingress_public.url)
except (AttributeError, KeyError):
pass
return self.add_explicit_port(
self.service_url(self.public_ingress_address)
)
@property
def admin_url(self) -> str:
"""Url for accessing the admin endpoint for this service."""
binding = self.model.get_binding("identity-service")
if binding and binding.network and binding.network.ingress_address:
return self.add_explicit_port(
self.service_url(str(binding.network.ingress_address))
)
raise sunbeam_guard.WaitingExceptionError(
"No admin address found for service"
)
@property
def internal_url(self) -> str:
"""Url for accessing the internal endpoint for this service."""
if hasattr(self, "ingress_internal"):
if self.ingress_internal.url:
logger.debug(
"Ingress-internal relation found, returning "
"ingress_internal.url of: %s",
self.ingress_internal.url,
)
return self.add_explicit_port(self.ingress_internal.url)
binding = self.model.get_binding("identity-service")
if binding and binding.network and binding.network.ingress_address:
return self.add_explicit_port(
self.service_url(str(binding.network.ingress_address))
)
raise sunbeam_guard.WaitingExceptionError(
"No internal address found for service"
)
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.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_period(self) -> str:
"""Healthcheck period for the service."""
return "10s" # Default value in pebble
@property
def healthcheck_http_url(self) -> str:
"""Healthcheck HTTP URL for the service."""
return f"http://localhost:{self.default_public_ingress_port}/"
@property
def healthcheck_http_timeout(self) -> str:
"""Healthcheck HTTP timeout for the service."""
return "3s"
def open_ports(self):
"""Register ports in underlying cloud."""
self.unit.open_port("tcp", self.default_public_ingress_port)
def add_explicit_port(self, org_url: str) -> str:
"""Update a url to add an explicit port.
Keystone auth endpoint parsing can give odd results if
an explicit port is missing.
"""
url = urllib.parse.urlparse(org_url)
new_netloc = url.netloc
if not url.port:
if url.scheme == "http":
new_netloc = url.netloc + ":80"
elif url.scheme == "https":
new_netloc = url.netloc + ":443"
return urllib.parse.urlunparse(
(
url.scheme,
new_netloc,
url.path,
url.params,
url.query,
url.fragment,
)
)