Update kubernetes_service_patch to v1

v1 of the kubernetes_service_patch lib will patches the service
definition on `status_update` event. This helps when Juju refreshes the
patched services to their initial state.

Depends-On: https://review.opendev.org/c/openstack/charm-ops-sunbeam/+/880270
Change-Id: I44ee58cb9f5ddee6339bcd207365a2dcb49ac006
Signed-off-by: Guillaume Boutry <guillaume.boutry@canonical.com>
This commit is contained in:
Guillaume Boutry 2023-04-13 10:40:11 +02:00
parent e8ea273461
commit 4accb5b01d
3 changed files with 117 additions and 54 deletions

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
echo "INFO: Fetching libs from charmhub." echo "INFO: Fetching libs from charmhub."
charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch charmcraft fetch-lib charms.observability_libs.v1.kubernetes_service_patch
charmcraft fetch-lib charms.ovn_central_k8s.v0.ovsdb charmcraft fetch-lib charms.ovn_central_k8s.v0.ovsdb
charmcraft fetch-lib charms.tls_certificates_interface.v1.tls_certificates charmcraft fetch-lib charms.tls_certificates_interface.v1.tls_certificates

View File

@ -9,21 +9,20 @@ service named after the application in the namespace (named after the Juju model
default contains a "placeholder" port, which is 65536/TCP. default contains a "placeholder" port, which is 65536/TCP.
When modifying the default set of resources managed by Juju, one must consider the lifecycle of the 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 charm. In this case, any modifications to the default service (created during deployment), will be
be overwritten during a charm upgrade. overwritten during a charm upgrade.
When initialised, this library binds a handler to the parent charm's `install` and `upgrade_charm` 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 events which applies the patch to the cluster. This should ensure that the service ports are
correct throughout the charm's life. correct throughout the charm's life.
The constructor simply takes a reference to the parent charm, and a list of tuples that each define The constructor simply takes a reference to the parent charm, and a list of
a port for the service, where each tuple contains: [`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).
- a name for the port Optionally, a name of the service (in case service name needs to be patched as well), labels,
- port for the service to listen on selectors, and annotations can be provided as keyword arguments.
- optionally: a targetPort for the service (the port in the container!)
- optionally: a nodePort for the service (for NodePort or LoadBalancer services only!)
- optionally: a name of the service (in case service name needs to be patched as well)
## Getting Started ## Getting Started
@ -32,8 +31,8 @@ that you also need to add `lightkube` and `lightkube-models` to your charm's `re
```shell ```shell
cd some-charm cd some-charm
charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch charmcraft fetch-lib charms.observability_libs.v1.kubernetes_service_patch
echo <<-EOF >> requirements.txt cat << EOF >> requirements.txt
lightkube lightkube
lightkube-models lightkube-models
EOF EOF
@ -41,28 +40,71 @@ EOF
Then, to initialise the library: Then, to initialise the library:
For ClusterIP services: For `ClusterIP` services:
```python ```python
# ... # ...
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase): class SomeCharm(CharmBase):
def __init__(self, *args): def __init__(self, *args):
# ... # ...
self.service_patcher = KubernetesServicePatch(self, [(f"{self.app.name}", 8080)]) port = ServicePort(443, name=f"{self.app.name}")
self.service_patcher = KubernetesServicePatch(self, [port])
# ... # ...
``` ```
For LoadBalancer/NodePort services: For `LoadBalancer`/`NodePort` services:
```python ```python
# ... # ...
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase): class SomeCharm(CharmBase):
def __init__(self, *args): def __init__(self, *args):
# ... # ...
port = ServicePort(443, name=f"{self.app.name}", targetPort=443, nodePort=30666)
self.service_patcher = KubernetesServicePatch( self.service_patcher = KubernetesServicePatch(
self, [(f"{self.app.name}", 443, 443, 30666)], "LoadBalancer" 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
) )
# ... # ...
``` ```
@ -83,15 +125,16 @@ def setUp(self, *unused):
import logging import logging
from types import MethodType from types import MethodType
from typing import Literal, Sequence, Tuple, Union from typing import List, Literal, Optional, Union
from lightkube import ApiError, Client from lightkube import ApiError, Client
from lightkube.core import exceptions
from lightkube.models.core_v1 import ServicePort, ServiceSpec from lightkube.models.core_v1 import ServicePort, ServiceSpec
from lightkube.models.meta_v1 import ObjectMeta from lightkube.models.meta_v1 import ObjectMeta
from lightkube.resources.core_v1 import Service from lightkube.resources.core_v1 import Service
from lightkube.types import PatchType from lightkube.types import PatchType
from ops.charm import CharmBase from ops.charm import CharmBase
from ops.framework import Object from ops.framework import BoundEvent, Object
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -99,13 +142,12 @@ logger = logging.getLogger(__name__)
LIBID = "0042f86d0a874435adef581806cddbbb" LIBID = "0042f86d0a874435adef581806cddbbb"
# Increment this major API version when introducing breaking changes # Increment this major API version when introducing breaking changes
LIBAPI = 0 LIBAPI = 1
# Increment this PATCH version before using `charmcraft publish-lib` or reset # Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version # to 0 if you are raising the major API version
LIBPATCH = 6 LIBPATCH = 6
PortDefinition = Union[Tuple[str, int], Tuple[str, int, int], Tuple[str, int, int, int]]
ServiceType = Literal["ClusterIP", "LoadBalancer"] ServiceType = Literal["ClusterIP", "LoadBalancer"]
@ -115,18 +157,20 @@ class KubernetesServicePatch(Object):
def __init__( def __init__(
self, self,
charm: CharmBase, charm: CharmBase,
ports: Sequence[PortDefinition], ports: List[ServicePort],
service_name: str = None, service_name: Optional[str] = None,
service_type: ServiceType = "ClusterIP", service_type: ServiceType = "ClusterIP",
additional_labels: dict = None, additional_labels: Optional[dict] = None,
additional_selectors: dict = None, additional_selectors: Optional[dict] = None,
additional_annotations: dict = None, additional_annotations: Optional[dict] = None,
*,
refresh_event: Optional[Union[BoundEvent, List[BoundEvent]]] = None,
): ):
"""Constructor for KubernetesServicePatch. """Constructor for KubernetesServicePatch.
Args: Args:
charm: the charm that is instantiating the library. charm: the charm that is instantiating the library.
ports: a list of tuples (name, port, targetPort, nodePort) for every service port. ports: a list of ServicePorts
service_name: allows setting custom name to the patched service. If none given, service_name: allows setting custom name to the patched service. If none given,
application name will be used. application name will be used.
service_type: desired type of K8s service. Default value is in line with ServiceSpec's service_type: desired type of K8s service. Default value is in line with ServiceSpec's
@ -136,6 +180,9 @@ class KubernetesServicePatch(Object):
additional_selectors: Selectors to be added to the kubernetes service (by default only additional_selectors: Selectors to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name) "app.kubernetes.io/name" is set to the service name)
additional_annotations: Annotations to be added to the kubernetes service. additional_annotations: Annotations to be added to the kubernetes service.
refresh_event: an optional bound event or list of bound events which
will be observed to re-apply the patch (e.g. on port change).
The `install` and `upgrade-charm` events would be observed regardless.
""" """
super().__init__(charm, "kubernetes-service-patch") super().__init__(charm, "kubernetes-service-patch")
self.charm = charm self.charm = charm
@ -154,23 +201,29 @@ class KubernetesServicePatch(Object):
# Ensure this patch is applied during the 'install' and 'upgrade-charm' events # 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.install, self._patch)
self.framework.observe(charm.on.upgrade_charm, self._patch) self.framework.observe(charm.on.upgrade_charm, self._patch)
self.framework.observe(charm.on.update_status, self._patch)
# apply user defined events
if refresh_event:
if not isinstance(refresh_event, list):
refresh_event = [refresh_event]
for evt in refresh_event:
self.framework.observe(evt, self._patch)
def _service_object( def _service_object(
self, self,
ports: Sequence[PortDefinition], ports: List[ServicePort],
service_name: str = None, service_name: Optional[str] = None,
service_type: ServiceType = "ClusterIP", service_type: ServiceType = "ClusterIP",
additional_labels: dict = None, additional_labels: Optional[dict] = None,
additional_selectors: dict = None, additional_selectors: Optional[dict] = None,
additional_annotations: dict = None, additional_annotations: Optional[dict] = None,
) -> Service: ) -> Service:
"""Creates a valid Service representation. """Creates a valid Service representation.
Args: Args:
ports: a list of tuples of the form (name, port) or (name, port, targetPort) ports: a list of ServicePorts
or (name, port, targetPort, nodePort) for every service port. If the 'targetPort'
is omitted, it is assumed to be equal to 'port', with the exception of NodePort
and LoadBalancer services, where all port numbers have to be specified.
service_name: allows setting custom name to the patched service. If none given, service_name: allows setting custom name to the patched service. If none given,
application name will be used. application name will be used.
service_type: desired type of K8s service. Default value is in line with ServiceSpec's service_type: desired type of K8s service. Default value is in line with ServiceSpec's
@ -203,15 +256,7 @@ class KubernetesServicePatch(Object):
), ),
spec=ServiceSpec( spec=ServiceSpec(
selector=selector, selector=selector,
ports=[ ports=ports,
ServicePort(
name=p[0],
port=p[1],
targetPort=p[2] if len(p) > 2 else p[1], # type: ignore[misc]
nodePort=p[3] if len(p) > 3 else None, # type: ignore[arg-type, misc]
)
for p in ports
],
type=service_type, type=service_type,
), ),
) )
@ -222,11 +267,15 @@ class KubernetesServicePatch(Object):
Raises: Raises:
PatchFailed: if patching fails due to lack of permissions, or otherwise. PatchFailed: if patching fails due to lack of permissions, or otherwise.
""" """
if not self.charm.unit.is_leader(): try:
client = Client()
except exceptions.ConfigError as e:
logger.warning("Error creating k8s client: %s", e)
return return
client = Client()
try: try:
if self._is_patched(client):
return
if self.service_name != self._app: if self.service_name != self._app:
self._delete_and_create_service(client) self._delete_and_create_service(client)
client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE) client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE)
@ -252,12 +301,25 @@ class KubernetesServicePatch(Object):
bool: A boolean indicating if the service patch has been applied. bool: A boolean indicating if the service patch has been applied.
""" """
client = Client() client = Client()
return self._is_patched(client)
def _is_patched(self, client: Client) -> bool:
# Get the relevant service from the cluster # Get the relevant service from the cluster
try:
service = client.get(Service, name=self.service_name, namespace=self._namespace) service = client.get(Service, name=self.service_name, namespace=self._namespace)
except ApiError as e:
if e.status.code == 404 and self.service_name != self._app:
return False
else:
logger.error("Kubernetes service get failed: %s", str(e))
raise
# Construct a list of expected ports, should the patch be applied # Construct a list of expected ports, should the patch be applied
expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports] expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports]
# Construct a list in the same manner, using the fetched service # Construct a list in the same manner, using the fetched service
fetched_ports = [(p.port, p.targetPort) for p in service.spec.ports] # type: ignore[attr-defined] # noqa: E501 fetched_ports = [
(p.port, p.targetPort) for p in service.spec.ports # type: ignore[attr-defined]
] # noqa: E501
return expected_ports == fetched_ports return expected_ports == fetched_ports
@property @property

View File

@ -42,9 +42,12 @@ import ops_sunbeam.ovn.config_contexts as ovn_ctxts
import ops_sunbeam.ovn.container_handlers as ovn_chandlers import ops_sunbeam.ovn.container_handlers as ovn_chandlers
import ops_sunbeam.ovn.relation_handlers as ovn_relation_handlers import ops_sunbeam.ovn.relation_handlers as ovn_relation_handlers
import ops_sunbeam.relation_handlers as sunbeam_rhandlers import ops_sunbeam.relation_handlers as sunbeam_rhandlers
from charms.observability_libs.v0.kubernetes_service_patch import ( from charms.observability_libs.v1.kubernetes_service_patch import (
KubernetesServicePatch, KubernetesServicePatch,
) )
from lightkube.models.core_v1 import (
ServicePort,
)
from ops.main import ( from ops.main import (
main, main,
) )
@ -97,9 +100,7 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm):
super().__init__(framework) super().__init__(framework)
self.service_patcher = KubernetesServicePatch( self.service_patcher = KubernetesServicePatch(
self, self,
[ [ServicePort(6642, name="southbound")],
("southbound", 6642),
],
service_type="LoadBalancer", service_type="LoadBalancer",
) )
self.framework.observe( self.framework.observe(