Skip to content

Commit

Permalink
chore: update charm libraries
Browse files Browse the repository at this point in the history
  • Loading branch information
observability-noctua-bot committed Dec 5, 2024
1 parent 3698ce9 commit a274b08
Show file tree
Hide file tree
Showing 6 changed files with 465 additions and 207 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def _on_certificate_removed(self, event: CertificateRemovedEvent):
from typing import List, Mapping

from jsonschema import exceptions, validate # type: ignore[import-untyped]
from ops import Relation
from ops.charm import CharmBase, CharmEvents, RelationBrokenEvent, RelationChangedEvent
from ops.framework import EventBase, EventSource, Handle, Object

Expand All @@ -112,7 +113,7 @@ def _on_certificate_removed(self, event: CertificateRemovedEvent):

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

PYDEPS = ["jsonschema"]

Expand Down Expand Up @@ -391,3 +392,11 @@ def _on_relation_broken(self, event: RelationBrokenEvent) -> None:
None
"""
self.on.certificate_removed.emit(relation_id=event.relation.id)

def is_ready(self, relation: Relation) -> bool:
"""Check if the relation is ready by checking that it has valid relation data."""
relation_data = _load_relation_data(relation.data[relation.app])
if not self._relation_data_is_valid(relation_data):
logger.warning("Provider relation data did not pass JSON Schema validation: ")
return False
return True
55 changes: 36 additions & 19 deletions lib/charms/observability_libs/v1/cert_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Since this library uses [Juju Secrets](https://juju.is/docs/juju/secret) it requires Juju >= 3.0.3.
"""
import abc
import hashlib
import ipaddress
import json
import socket
Expand Down Expand Up @@ -67,7 +68,7 @@

LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a"
LIBAPI = 1
LIBPATCH = 13
LIBPATCH = 15

VAULT_SECRET_LABEL = "cert-handler-private-vault"

Expand Down Expand Up @@ -126,7 +127,7 @@ class _RelationVaultBackend(_VaultBackend):
_NEST_UNDER = "lib.charms.observability_libs.v1.cert_handler::vault"
# This key needs to be relation-unique. If someone ever creates multiple Vault(_RelationVaultBackend)
# instances backed by the same (peer) relation, they'll need to set different _NEST_UNDERs
# for each _RelationVaultBackend instance or they'll be fighting over it.
# for each _RelationVaultBackend instance, or they'll be fighting over it.

def __init__(self, charm: CharmBase, relation_name: str):
self.charm = charm
Expand Down Expand Up @@ -301,14 +302,11 @@ 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.
refresh_events: [DEPRECATED].
"""
super().__init__(charm, key)
# use StoredState to store the hash of the CSR
# to potentially trigger a CSR renewal on `refresh_events`
# to potentially trigger a CSR renewal
self._stored.set_default(
csr_hash=None,
)
Expand All @@ -320,8 +318,9 @@ def __init__(

# Use fqdn only if no SANs were given, and drop empty/duplicate SANs
sans = list(set(filter(None, (sans or [socket.getfqdn()]))))
self.sans_ip = list(filter(is_ip_address, sans))
self.sans_dns = list(filterfalse(is_ip_address, sans))
# sort SANS lists to avoid unnecessary csr renewals during reconciliation
self.sans_ip = sorted(filter(is_ip_address, sans))
self.sans_dns = sorted(filterfalse(is_ip_address, sans))

if self._check_juju_supports_secrets():
vault_backend = _SecretVaultBackend(charm, secret_label=VAULT_SECRET_LABEL)
Expand All @@ -345,6 +344,13 @@ def __init__(
self.charm.on[self.certificates_relation_name].relation_joined, # pyright: ignore
self._on_certificates_relation_joined,
)
# The following observer is a workaround. The tls-certificates lib sometimes fails to emit the custom
# "certificate_available" event on relation changed. Not sure why this was happening. We certainly have some
# tech debt here to address, but this workaround proved to work.
self.framework.observe(
self.charm.on[self.certificates_relation_name].relation_changed, # pyright: ignore
self._on_certificate_available,
)
self.framework.observe(
self.certificates.on.certificate_available, # pyright: ignore
self._on_certificate_available,
Expand All @@ -367,13 +373,15 @@ def __init__(
)

if refresh_events:
for ev in refresh_events:
self.framework.observe(ev, self._on_refresh_event)
logger.warning(
"DEPRECATION WARNING. `refresh_events` is now deprecated. CertHandler will automatically refresh the CSR when necessary."
)

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)
self._reconcile()

def _reconcile(self):
"""Run all logic that is independent of what event we're processing."""
self._refresh_csr_if_needed()

def _on_upgrade_charm(self, _):
has_privkey = self.vault.get_value("private-key")
Expand All @@ -388,6 +396,11 @@ def _on_upgrade_charm(self, _):
# this will call `self.private_key` which will generate a new privkey.
self._generate_csr(renew=True)

def _refresh_csr_if_needed(self):
"""Refresh the current CSR with a new one if there are any SANs changes."""
if self._stored.csr_hash is not None and self._stored.csr_hash != self._csr_hash:
self._generate_csr(renew=True)

def _migrate_vault(self):
peer_backend = _RelationVaultBackend(self.charm, relation_name="peers")

Expand Down Expand Up @@ -423,7 +436,7 @@ def enabled(self) -> bool:
See also the `available` property.
"""
# We need to check for units as a temporary workaround because of https://bugs.launchpad.net/juju/+bug/2024583
# This could in theory not work correctly on scale down to 0 but it is necessary for the moment.
# This could in theory not work correctly on scale down to 0, but it is necessary for the moment.

if not self.relation:
return False
Expand All @@ -440,13 +453,17 @@ def enabled(self) -> bool:
return True

@property
def _csr_hash(self) -> int:
def _csr_hash(self) -> str:
"""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(

def _stable_hash(data):
return hashlib.sha256(str(data).encode()).hexdigest()

return _stable_hash(
(
tuple(self.sans_dns),
tuple(self.sans_ip),
Expand Down Expand Up @@ -626,7 +643,7 @@ def _on_all_certificates_invalidated(self, _: AllCertificatesInvalidatedEvent) -
# Note: assuming "limit: 1" in metadata
# The "certificates_relation_broken" event is converted to "all invalidated" custom
# event by the tls-certificates library. Per convention, we let the lib manage the
# relation and we do not observe "certificates_relation_broken" directly.
# relation, and we do not observe "certificates_relation_broken" directly.
self.vault.clear()
# We do not generate a CSR here because the relation is gone.
self.on.cert_changed.emit() # pyright: ignore
Expand Down
154 changes: 13 additions & 141 deletions lib/charms/observability_libs/v1/kubernetes_service_patch.py
Original file line number Diff line number Diff line change
@@ -1,146 +1,13 @@
# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.

"""# KubernetesServicePatch Library.
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 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
overwritten during a charm upgrade.
When initialised, this library binds a handler to the parent charm's `install` and `upgrade_charm`
events which applies the patch to the cluster. This should ensure that the service ports are
correct throughout the charm's life.
The constructor simply takes a reference to the parent charm, and a list of
[`lightkube`](https://github.com/gtsystem/lightkube) ServicePorts that each define a port for the
service. For information regarding the `lightkube` `ServicePort` model, please visit the
`lightkube` [docs](https://gtsystem.github.io/lightkube-models/1.23/models/core_v1/#serviceport).
Optionally, a name of the service (in case service name needs to be patched as well), labels,
selectors, and annotations can be provided as keyword arguments.
## Getting Started
To get started using the library, you just need to fetch the library using `charmcraft`. **Note
that you also need to add `lightkube` and `lightkube-models` to your charm's `requirements.txt`.**
```shell
cd some-charm
charmcraft fetch-lib charms.observability_libs.v1.kubernetes_service_patch
cat << EOF >> requirements.txt
lightkube
lightkube-models
EOF
```
Then, to initialise the library:
For `ClusterIP` services:
```python
# ...
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
port = ServicePort(443, name=f"{self.app.name}")
self.service_patcher = KubernetesServicePatch(self, [port])
# ...
```
For `LoadBalancer`/`NodePort` services:
```python
# ...
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
port = ServicePort(443, name=f"{self.app.name}", targetPort=443, nodePort=30666)
self.service_patcher = KubernetesServicePatch(
self, [port], "LoadBalancer"
)
# ...
```
Port protocols can also be specified. Valid protocols are `"TCP"`, `"UDP"`, and `"SCTP"`
```python
# ...
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
tcp = ServicePort(443, name=f"{self.app.name}-tcp", protocol="TCP")
udp = ServicePort(443, name=f"{self.app.name}-udp", protocol="UDP")
sctp = ServicePort(443, name=f"{self.app.name}-sctp", protocol="SCTP")
self.service_patcher = KubernetesServicePatch(self, [tcp, udp, sctp])
# ...
```
Bound with custom events by providing `refresh_event` argument:
For example, you would like to have a configurable port in your charm and want to apply
service patch every time charm config is changed.
```python
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
port = ServicePort(int(self.config["charm-config-port"]), name=f"{self.app.name}")
self.service_patcher = KubernetesServicePatch(
self,
[port],
refresh_event=self.on.config_changed
)
# ...
```
Creating a new k8s lb service instead of patching the one created by juju
Service name is optional. If not provided, it defaults to {app_name}-lb.
If provided and equal to app_name, it also defaults to {app_name}-lb to prevent conflicts with the Juju default service.
```python
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
port = ServicePort(int(self.config["charm-config-port"]), name=f"{self.app.name}")
self.service_patcher = KubernetesServicePatch(
self,
[port],
service_type="LoadBalancer",
service_name="application-lb"
)
# ...
```
Additionally, you may wish to use mocks in your charm's unit testing to ensure that the library
does not try to make any API calls, or open any files during testing that are unlikely to be
present, and could break your tests. The easiest way to do this is during your test `setUp`:
```python
# ...
@patch("charm.KubernetesServicePatch", lambda x, y: None)
def setUp(self, *unused):
self.harness = Harness(SomeCharm)
# ...
```
"""# [DEPRECATED!] KubernetesServicePatch Library.
The `kubernetes_service_patch` library is DEPRECATED and will be removed in October 2025.
For patching the Kubernetes service created by Juju during the deployment of a charm,
`ops.Unit.set_ports` functionality should be used instead.
"""

import logging
Expand All @@ -167,7 +34,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 = 12
LIBPATCH = 13

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

Expand Down Expand Up @@ -205,6 +72,11 @@ def __init__(
will be observed to re-apply the patch (e.g. on port change).
The `install` and `upgrade-charm` events would be observed regardless.
"""
logger.warning(
"The ``kubernetes_service_patch v1`` library is DEPRECATED and will be removed "
"in October 2025. For patching the Kubernetes service created by Juju during "
"the deployment of a charm, ``ops.Unit.set_ports`` functionality should be used instead."
)
super().__init__(charm, "kubernetes-service-patch")
self.charm = charm
self.service_name = service_name or self._app
Expand Down
Loading

0 comments on commit a274b08

Please sign in to comment.