Files
sunbeam-charms/charms/sunbeam-clusterd/src/charm.py
T
Ahmad Hassan b3387c4ff1 [openstack-hypervisor] error out on snap installation failure
When the openstack-hypervisor snap fails to install, then set
the unit status to error instead of blocked.

Closes-Bug: #2065944.

Change-Id: I0b3817c8e8321e5bfd8eaabe63646c9cd48cdf36
2025-01-28 11:21:39 +05:00

629 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright 2024 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.
"""Sunbeam Clusterd Charm.
This charm manages a clusterd deployment. Clusterd is a service storing
every metadata about a sunbeam deployment.
"""
import hashlib
import logging
import socket
from pathlib import (
Path,
)
import clusterd
import ops
import ops.framework
import ops_sunbeam.charm as sunbeam_charm
import ops_sunbeam.guard as sunbeam_guard
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
import ops_sunbeam.tracing as sunbeam_tracing
import requests
import tenacity
from charms.operator_libs_linux.v2 import (
snap,
)
from charms.tls_certificates_interface.v3.tls_certificates import (
generate_csr,
)
from cryptography import (
x509,
)
from ops_sunbeam.relation_handlers import (
RelationHandler,
)
from relation_handlers import (
ClusterdNewNodeEvent,
ClusterdNodeAddedEvent,
ClusterdPeerHandler,
ClusterdRemoveNodeEvent,
)
logger = logging.getLogger(__name__)
def _identity(x: bool) -> bool:
return x
class SnapInstallationError(Exception):
"""Custom exception for snap installation failure errors."""
@sunbeam_tracing.trace_type
class ClusterCertificatesHandler(sunbeam_rhandlers.TlsCertificatesHandler):
"""Handler for certificates interface."""
def get_entity(self) -> ops.Unit | ops.Application:
"""Return the entity for the key store."""
return self.charm.model.app
def key_names(self) -> list[str]:
"""Return the key names managed by this relation.
First key is considered as default key.
"""
return ["main", "client"]
def csrs(self) -> dict[str, bytes]:
"""Return a dict of generated csrs for self.key_names().
The method calling this method will ensure that all keys have a matching
csr.
"""
main_key = self._private_keys.get("main")
client_key = self._private_keys.get("client")
if not main_key or not client_key:
return {}
return {
"main": generate_csr(
private_key=main_key.encode(),
subject=self.charm.app.name,
sans_ip=self.sans_ips,
sans_dns=self.sans_dns,
additional_critical_extensions=[
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=True,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
x509.ExtendedKeyUsage({x509.OID_SERVER_AUTH}),
],
),
"client": generate_csr(
private_key=client_key.encode(),
subject=self.charm.app.name + "-client",
additional_critical_extensions=[
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=True,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
x509.ExtendedKeyUsage({x509.OID_CLIENT_AUTH}),
],
),
}
def get_client_keypair(self) -> dict[str, str]:
"""Return client keypair with the CA."""
client_key = self.store.get_private_key("client")
client_csr = self.store.get_csr("client")
if client_key is None or client_csr is None:
return {}
for cert in self.get_certs():
if cert.csr == client_csr:
return {
"certificate-authority": cert.ca,
"certificate": cert.certificate,
"private-key-secret": client_key,
}
return {}
@sunbeam_tracing.trace_sunbeam_charm
class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
"""Charm the service."""
_state = ops.StoredState()
service_name = "sunbeam-clusterd"
clusterd_port = 7000
def __init__(self, framework: ops.Framework) -> None:
"""Run constructor."""
super().__init__(framework)
self._state.set_default(
channel="config", departed=False, certs_hash=""
)
self.framework.observe(self.on.install, self._on_install)
self.framework.observe(self.on.stop, self._on_stop)
self.framework.observe(
self.on.get_credentials_action, self._on_get_credentials_action
)
self._clusterd = clusterd.ClusterdClient(
Path("/var/snap/openstack/common/state/control.socket")
)
def get_relation_handlers(
self, handlers: list[RelationHandler] | None = None
) -> list[RelationHandler]:
"""Setup charm relation handlers."""
handlers = handlers or []
if self.can_add_handler("peers", handlers):
self.peers = ClusterdPeerHandler(
self,
"peers",
self.configure_charm,
"peers" in self.mandatory_relations,
)
handlers.append(self.peers)
if self.can_add_handler("certificates", handlers):
self.certs = ClusterCertificatesHandler(
self,
"certificates",
self.configure_charm,
self.get_domain_name_sans(),
self.get_sans_ips(),
mandatory="certificates" in self.mandatory_relations,
)
handlers.append(self.certs)
return super().get_relation_handlers(handlers)
def get_domain_name_sans(self) -> list[str]:
"""Return domain name sans."""
return [socket.gethostname()]
def get_sans_ips(self) -> list[str]:
"""Return Subject Alternate Names to use in cert for service."""
return ["127.0.0.1"]
def _on_install(self, event: ops.InstallEvent) -> None:
"""Handle install event."""
try:
with sunbeam_guard.guard(self, "Ensure snap installation", False):
self.ensure_snap_present()
except TimeoutError:
logger.debug("Snap installation failed, retrying.")
event.defer()
return
self.clusterd_ready()
self.status.set(
ops.WaitingStatus("Waiting for clusterd initialization")
)
def _on_stop(self, event: ops.StopEvent) -> None:
"""Handle stop event."""
try:
self._clusterd.shutdown()
except clusterd.ClusterdUnavailableError:
logger.debug("Clusterd not available, skipping shutdown.")
snap.SnapCache()["openstack"].stop()
def _on_get_credentials_action(self, event: ops.ActionEvent) -> None:
"""Handle get-credentials action."""
if not self.peers.interface.state.joined:
event.fail("Clusterd not joined yet")
return
credentials = {}
if relation := self.model.get_relation(self.certs.relation_name):
if relation.active:
credentials = self.certs.get_client_keypair()
if not credentials:
event.fail("No credentials found yet")
return
event.set_results(
{
"url": "https://"
+ self._binding_address()
+ ":"
+ str(self.clusterd_port),
**credentials,
}
)
def _binding_address(self) -> str:
"""Return the binding address."""
relation = self.model.get_relation("peers")
if relation is None:
raise ValueError("Missing relation peers")
binding = self.model.get_binding(relation)
if binding is None:
raise ValueError("Missing binding peers")
if binding.network.bind_address is None:
raise ValueError("Missing binding address")
return str(binding.network.bind_address)
def ensure_snap_present(self):
"""Install/refresh snap if needed."""
config = self.model.config.get
snap_channel = config("snap-channel")
try:
cache = snap.SnapCache()
openstack = cache["openstack"]
if not openstack.present or snap_channel != openstack.channel:
openstack.ensure(snap.SnapState.Latest, channel=snap_channel)
self._state.channel = openstack.channel
self.set_workload_version()
except (snap.SnapError, snap.SnapNotFoundError) as e:
logger.error(
"An exception occurred when installing snap. Reason: %s",
e.message,
)
raise SnapInstallationError("openstack snap installation failed")
def set_workload_version(self):
"""Set workload version."""
cache = snap.SnapCache()
openstack = cache["openstack"]
if not openstack.present:
return
version = openstack.channel + f"(rev {openstack.revision})"
self.unit.set_workload_version(version)
def configure_app_leader(self, event: ops.EventBase):
"""Configure leader unit."""
if not self.clusterd_ready():
logger.debug("Clusterd not ready yet.")
event.defer()
raise sunbeam_guard.WaitingExceptionError("Clusterd not ready yet")
if not self.is_leader_ready():
self.bootstrap_cluster()
self.peers.interface.state.joined = True
self.configure_certificates()
super().configure_app_leader(event)
if isinstance(event, ClusterdNewNodeEvent):
self.add_node_to_cluster(event)
def configure_app_non_leader(self, event: ops.EventBase):
"""Configure non-leader unit."""
super().configure_app_non_leader(event)
if isinstance(event, ClusterdNodeAddedEvent):
self.join_node_to_cluster(event)
def configure_unit(self, event: ops.EventBase):
"""Configure unit."""
super().configure_unit(event)
if isinstance(event, ClusterdRemoveNodeEvent):
self.remove_node_from_cluster(event)
self.ensure_snap_present()
config = self.model.config.get
snap_data = {
"daemon.debug": config("debug", False),
}
self.set_snap_data(snap_data)
def configure_certificates(self):
"""Configure certificates."""
if not self.unit.is_leader():
logger.debug("Not leader, skipping certificate configuration.")
return
if not self.certs.ready:
logger.debug("Certificates not ready yet.")
return
certs = self.certs.context()
certs_hash = hashlib.sha256(bytes(str(certs), "utf-8")).hexdigest()
if certs_hash == self._state.certs_hash:
logger.debug("Certificates have not changed.")
return
self._clusterd.set_certs(
ca=certs["ca_cert_main"],
key=certs["key_main"],
cert=certs["cert_main"],
)
self._state.certs_hash = certs_hash
def set_snap_data(self, snap_data: dict):
"""Set snap data on local snap."""
cache = snap.SnapCache()
openstack = cache["openstack"]
new_settings = {}
for k in sorted(snap_data.keys()):
try:
if snap_data[k] != openstack.get(k):
new_settings[k] = snap_data[k]
except snap.SnapError:
# Trying to retrieve an unset parameter results in a snapError
# so assume the snap.SnapError means there is missing config
# that needs setting.
new_settings[k] = snap_data[k]
if new_settings:
logger.debug(f"Applying new snap settings {new_settings}")
openstack.set(new_settings, typed=True)
else:
logger.debug("Snap settings do not need updating")
@tenacity.retry(
stop=tenacity.stop_after_attempt(10),
retry=(
tenacity.retry_if_exception_type(clusterd.ClusterdUnavailableError)
| tenacity.retry_if_not_result(_identity)
),
after=tenacity.after_log(logger, logging.WARNING),
wait=tenacity.wait_exponential(multiplier=1, min=1, max=30),
)
def clusterd_ready(self) -> bool:
"""Check whether clusterd is ready."""
if not self._clusterd.ready():
return False
return True
def bootstrap_cluster(self):
"""Bootstrap the cluster."""
logger.info("Bootstrapping the cluster")
self._clusterd.bootstrap(
self.unit.name.replace("/", "-"),
self._binding_address() + ":" + str(self.clusterd_port),
)
def add_node_to_cluster(self, event: ClusterdNewNodeEvent) -> None:
"""Generate token for node joining."""
if event.unit is None:
logger.debug("No unit to add")
return
unit_key = f"{event.unit.name}.join_token"
if self.peers.get_app_data(unit_key):
logger.debug(f"Already generated token for {event.unit.name}")
return
try:
token = self._clusterd.generate_token(
event.unit.name.replace("/", "-")
)
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code >= 500:
logger.error(f"Clusterd error: {str(e)}")
logger.debug("Failed to generate token, retrying.")
event.defer()
return
raise e
self.peers.set_app_data({unit_key: token})
def remove_node_from_cluster(self, event: ClusterdRemoveNodeEvent) -> None:
"""Remove node from cluster."""
if event.departing_unit is None:
logger.debug("No unit to remove")
return
self_departing = event.departing_unit.name == self.unit.name
already_left = self._wait_for_roles_to_settle_before_removal(
event, self_departing
)
if already_left:
return
logger.debug(f"Departing unit: {event.departing_unit.name}")
self._remove_member_from_cluster(event.departing_unit.name)
if self.model.unit.is_leader():
departing_key = f"{event.departing_unit.name}.join_token"
self.peers.interface._app_data_bag.pop(
departing_key,
None,
)
if self_departing:
self.status.set(ops.WaitingStatus("Waiting for removal"))
member_left = self._wait_until_local_member_left_cluster()
if member_left:
return
logger.warning(
"Member %s has not left the cluster yet",
event.departing_unit.name,
)
event.defer()
raise sunbeam_guard.WaitingExceptionError(
"Waiting for member to leave cluster"
)
self.status.set(ops.WaitingStatus("Waiting for roles to settle"))
if not self._wait_until_roles_are_settled():
logger.debug("Roles not settled yet")
event.defer()
raise sunbeam_guard.WaitingExceptionError(
"Waiting for roles to settle"
)
def _wait_for_roles_to_settle_before_removal(
self, event: ops.EventBase, self_departing: bool
) -> bool:
"""This method waits for rols to settle before removing a member.
Returns true if the member has already left the cluster.
"""
if self_departing:
# We are the departing unit, and we might be the leader
message = "Waiting for roles to settle before leaving cluster"
else:
# We are the leader, not the departing unit
message = "Waiting for roles to settle before removing member"
self.status.set(ops.WaitingStatus(message))
# Leaving while the roles are not settled can cause the cluster to
# be in an inconsistent state. So we wait until the roles are
# settled before leaving.
try:
if not self._wait_until_roles_are_settled():
logger.debug("Roles not settled yet")
event.defer()
raise sunbeam_guard.WaitingExceptionError(message)
except requests.exceptions.HTTPError as e:
if (
e.response is not None
and "Daemon not yet initialized" in e.response.text
):
if self_departing:
logger.debug("Member already left cluster")
return True
return False
def _remove_member_from_cluster(self, departing_unit: str):
"""Helper method to remove a member from the cluster."""
member_name = departing_unit.replace("/", "-")
self_departing = departing_unit == self.unit.name
try:
logger.debug(f"Removing member {member_name}")
self._clusterd.remove_node(
member_name,
force=True,
allow_not_found=True,
)
except clusterd.ClusterdUnavailableError as e:
if "Remote end closed connection without response" in str(e):
logger.debug(
"Forwarded request failed, most likely because member was leader"
" and this member was removed."
)
else:
raise e
except requests.exceptions.HTTPError as e:
if e.response is None:
raise e
is_503 = e.response.status_code == 503
is_500 = e.response.status_code == 500
if not self_departing or not (is_503 or is_500):
raise e
logger.debug(
"Clusterd is not initialized, most likely because"
" leader has already removed this unit from clusterd."
" Error: %s",
e.response.text, # type: ignore
)
def join_node_to_cluster(self, event: ClusterdNodeAddedEvent) -> None:
"""Join node to cluster."""
self.status.set(ops.MaintenanceStatus("Joining cluster"))
token = self.peers.get_app_data(f"{self.unit.name}.join_token")
if token is None:
logger.warning("No token found for unit %s", self.unit.name)
return
member = self.unit.name.replace("/", "-")
if not self.peers.interface.state.joined:
self._clusterd.join(
member,
self._binding_address() + ":" + str(self.clusterd_port),
token,
)
self.peers.interface.state.joined = True
self.peers.set_unit_data({"joined": "true"})
self.status.set(ops.WaitingStatus("Waiting for clusterd role"))
is_role_set = self._wait_until_role_set(member)
if not is_role_set:
logger.debug("Member %s is still pending", member)
event.defer()
return
def _wait_until_role_set(self, name: str) -> bool:
@tenacity.retry(
wait=tenacity.wait_fixed(5),
stop=tenacity.stop_after_delay(300),
retry=tenacity.retry_if_not_result(_identity),
)
def _wait_until_role_set(name: str) -> bool:
member = self._clusterd.get_member(name)
role = member.get("role")
logger.debug(f"Member {name} role: {role}")
if role == "PENDING":
return False
return True
try:
return _wait_until_role_set(name)
except tenacity.RetryError:
return False
def _wait_until_roles_are_settled(self) -> bool:
"""Wait until cluster has odd number of voters."""
@tenacity.retry(
wait=tenacity.wait_fixed(5),
stop=tenacity.stop_after_delay(60),
retry=tenacity.retry_if_not_result(_identity),
)
def _wait_until_roles_are_settled() -> bool:
members = self._clusterd.get_members()
voter = 0
for member in members:
if member.get("role") == "voter":
voter += 1
if voter % 2 == 0:
return False
return True
try:
return _wait_until_roles_are_settled()
except tenacity.RetryError:
return False
def _wait_until_local_member_left_cluster(self) -> bool:
"""Wait until local node has left the cluster."""
@tenacity.retry(
wait=tenacity.wait_fixed(5),
stop=tenacity.stop_after_delay(60),
retry=tenacity.retry_if_not_result(_identity),
)
def _wait_until_local_member_left_cluster() -> bool:
member_name = self.unit.name.replace("/", "-")
try:
self._clusterd.get_member(member_name)
return False
except requests.exceptions.HTTPError as e:
if e.response is None:
raise e
db_closed = "database is closed" in e.response.text
clusterd_not_initialized = (
"Daemon not yet initialized" in e.response.text
)
if db_closed or clusterd_not_initialized:
logger.debug(
"Clusterd returned a known error while waiting for removal."
". Skipping."
" Error: %s",
e.response.text,
)
return True
raise e
try:
return _wait_until_local_member_left_cluster()
except tenacity.RetryError:
return False
if __name__ == "__main__": # pragma: nocover
ops.main(SunbeamClusterdCharm)