Skip to content

Commit

Permalink
chore: update charm libraries (#124)
Browse files Browse the repository at this point in the history
  • Loading branch information
observability-noctua-bot authored Sep 13, 2024
1 parent 280bc1e commit 0cf4684
Show file tree
Hide file tree
Showing 6 changed files with 458 additions and 174 deletions.
42 changes: 39 additions & 3 deletions charm/lib/charms/observability_libs/v1/cert_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
self.framework.observe(self.cert_handler.on.cert_changed, self._on_server_cert_changed)
container.push(keypath, self.cert_handler.private_key)
container.push(certpath, self.cert_handler.servert_cert)
container.push(certpath, self.cert_handler.server_cert)
```
Since this library uses [Juju Secrets](https://juju.is/docs/juju/secret) it requires Juju >= 3.0.3.
Expand Down Expand Up @@ -59,15 +59,15 @@
import logging

from ops.charm import CharmBase
from ops.framework import EventBase, EventSource, Object, ObjectEvents
from ops.framework import BoundEvent, EventBase, EventSource, Object, ObjectEvents, StoredState
from ops.jujuversion import JujuVersion
from ops.model import Relation, Secret, SecretNotFoundError

logger = logging.getLogger(__name__)

LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a"
LIBAPI = 1
LIBPATCH = 11
LIBPATCH = 12

VAULT_SECRET_LABEL = "cert-handler-private-vault"

Expand Down Expand Up @@ -273,6 +273,7 @@ class CertHandler(Object):
"""A wrapper for the requirer side of the TLS Certificates charm library."""

on = CertHandlerEvents() # pyright: ignore
_stored = StoredState()

def __init__(
self,
Expand All @@ -283,6 +284,7 @@ def __init__(
peer_relation_name: str = "peers",
cert_subject: Optional[str] = None,
sans: Optional[List[str]] = None,
refresh_events: Optional[List[BoundEvent]] = None,
):
"""CertHandler is used to wrap TLS Certificates management operations for charms.
Expand All @@ -299,8 +301,17 @@ def __init__(
Must match metadata.yaml.
cert_subject: Custom subject. Name collisions are under the caller's responsibility.
sans: DNS names. If none are given, use FQDN.
refresh_events: an optional list of bound events which
will be observed to replace the current CSR with a new one
if there are changes in the CSR's DNS SANs or IP SANs.
Then, subsequently, replace its corresponding certificate with a new one.
"""
super().__init__(charm, key)
# use StoredState to store the hash of the CSR
# to potentially trigger a CSR renewal on `refresh_events`
self._stored.set_default(
csr_hash=None,
)
self.charm = charm

# We need to sanitize the unit name, otherwise route53 complains:
Expand Down Expand Up @@ -355,6 +366,15 @@ def __init__(
self._on_upgrade_charm,
)

if refresh_events:
for ev in refresh_events:
self.framework.observe(ev, self._on_refresh_event)

def _on_refresh_event(self, _):
"""Replace the latest current CSR with a new one if there are any SANs changes."""
if self._stored.csr_hash != self._csr_hash:
self._generate_csr(renew=True)

def _on_upgrade_charm(self, _):
has_privkey = self.vault.get_value("private-key")

Expand Down Expand Up @@ -419,6 +439,20 @@ def enabled(self) -> bool:

return True

@property
def _csr_hash(self) -> int:
"""A hash of the config that constructs the CSR.
Only include here the config options that, should they change, should trigger a renewal of
the CSR.
"""
return hash(
(
tuple(self.sans_dns),
tuple(self.sans_ip),
)
)

@property
def available(self) -> bool:
"""Return True if all certs are available in relation data; False otherwise."""
Expand Down Expand Up @@ -484,6 +518,8 @@ def _generate_csr(
)
self.certificates.request_certificate_creation(certificate_signing_request=csr)

self._stored.csr_hash = self._csr_hash

if clear_cert:
self.vault.clear()

Expand Down
51 changes: 42 additions & 9 deletions charm/lib/charms/observability_libs/v1/kubernetes_service_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
This library is designed to enable developers to more simply patch the Kubernetes Service created
by Juju during the deployment of a sidecar charm. When sidecar charms are deployed, Juju creates a
service named after the application in the namespace (named after the Juju model). This service by
default contains a "placeholder" port, which is 65536/TCP.
default contains a "placeholder" port, which is 65535/TCP.
When modifying the default set of resources managed by Juju, one must consider the lifecycle of the
charm. In this case, any modifications to the default service (created during deployment), will be
Expand Down Expand Up @@ -145,14 +145,15 @@ def setUp(self, *unused):

import logging
from types import MethodType
from typing import List, Literal, Optional, Union
from typing import Any, List, Literal, Optional, Union

from lightkube import ApiError, Client # pyright: ignore
from lightkube.core import exceptions
from lightkube.models.core_v1 import ServicePort, ServiceSpec
from lightkube.models.meta_v1 import ObjectMeta
from lightkube.resources.core_v1 import Service
from lightkube.types import PatchType
from ops import UpgradeCharmEvent
from ops.charm import CharmBase
from ops.framework import BoundEvent, Object

Expand All @@ -166,7 +167,7 @@ def setUp(self, *unused):

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 10
LIBPATCH = 12

ServiceType = Literal["ClusterIP", "LoadBalancer"]

Expand Down Expand Up @@ -225,9 +226,11 @@ def __init__(
assert isinstance(self._patch, MethodType)
# Ensure this patch is applied during the 'install' and 'upgrade-charm' events
self.framework.observe(charm.on.install, self._patch)
self.framework.observe(charm.on.upgrade_charm, self._patch)
self.framework.observe(charm.on.upgrade_charm, self._on_upgrade_charm)
self.framework.observe(charm.on.update_status, self._patch)
self.framework.observe(charm.on.stop, self._remove_service)
# Sometimes Juju doesn't clean-up a manually created LB service,
# so we clean it up ourselves just in case.
self.framework.observe(charm.on.remove, self._remove_service)

# apply user defined events
if refresh_event:
Expand Down Expand Up @@ -356,6 +359,36 @@ def _is_patched(self, client: Client) -> bool:
] # noqa: E501
return expected_ports == fetched_ports

def _on_upgrade_charm(self, event: UpgradeCharmEvent):
"""Handle the upgrade charm event."""
# If a charm author changed the service type from LB to ClusterIP across an upgrade, we need to delete the previous LB.
if self.service_type == "ClusterIP":

client = Client() # pyright: ignore

# Define a label selector to find services related to the app
selector: dict[str, Any] = {"app.kubernetes.io/name": self._app}

# Check if any service of type LoadBalancer exists
services = client.list(Service, namespace=self._namespace, labels=selector)
for service in services:
if (
not service.metadata
or not service.metadata.name
or not service.spec
or not service.spec.type
):
logger.warning(
"Service patch: skipping resource with incomplete metadata: %s.", service
)
continue
if service.spec.type == "LoadBalancer":
client.delete(Service, service.metadata.name, namespace=self._namespace)
logger.info(f"LoadBalancer service {service.metadata.name} deleted.")

# Continue the upgrade flow normally
self._patch(event)

def _remove_service(self, _):
"""Remove a Kubernetes service associated with this charm.
Expand All @@ -372,13 +405,13 @@ def _remove_service(self, _):

try:
client.delete(Service, self.service_name, namespace=self._namespace)
logger.info("The patched k8s service '%s' was deleted.", self.service_name)
except ApiError as e:
if e.status.code == 404:
# Service not found, so no action needed
pass
else:
# Re-raise for other statuses
raise
return
# Re-raise for other statuses
raise

@property
def _app(self) -> str:
Expand Down
Loading

0 comments on commit 0cf4684

Please sign in to comment.