Skip to content

Commit

Permalink
add http_bind arg
Browse files Browse the repository at this point in the history
  • Loading branch information
sed-i committed Jan 26, 2022
1 parent 625cdb4 commit a4b75dc
Show file tree
Hide file tree
Showing 5 changed files with 314 additions and 16 deletions.
280 changes: 280 additions & 0 deletions lib/charms/observability_libs/v0/kubernetes_service_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
# 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 65536/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 tuples that each define
a port for the service, where each tuple contains:
- a name for the port
- port for the service to listen on
- 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
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.v0.kubernetes_service_patch
echo <<-EOF >> requirements.txt
lightkube
lightkube-models
EOF
```
Then, to initialise the library:
For ClusterIP services:
```python
# ...
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
self.service_patcher = KubernetesServicePatch(self, [(f"{self.app.name}", 8080)])
# ...
```
For LoadBalancer/NodePort services:
```python
# ...
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
self.service_patcher = KubernetesServicePatch(
self, [(f"{self.app.name}", 443, 443, 30666)], "LoadBalancer"
)
# ...
```
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)
# ...
```
"""

import logging
from types import MethodType
from typing import Literal, Sequence, Tuple, Union

from lightkube import ApiError, Client
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.charm import CharmBase
from ops.framework import Object

logger = logging.getLogger(__name__)

# The unique Charmhub library identifier, never change it
LIBID = "0042f86d0a874435adef581806cddbbb"

# Increment this major API version when introducing breaking changes
LIBAPI = 0

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

PortDefinition = Union[Tuple[str, int], Tuple[str, int, int], Tuple[str, int, int, int]]
ServiceType = Literal["ClusterIP", "LoadBalancer"]


class KubernetesServicePatch(Object):
"""A utility for patching the Kubernetes service set up by Juju."""

def __init__(
self,
charm: CharmBase,
ports: Sequence[PortDefinition],
service_name: str = None,
service_type: ServiceType = "ClusterIP",
additional_labels: dict = None,
additional_selectors: dict = None,
additional_annotations: dict = None,
):
"""Constructor for KubernetesServicePatch.
Args:
charm: the charm that is instantiating the library.
ports: a list of tuples (name, port, targetPort, nodePort) for every service port.
service_name: allows setting custom name to the patched service. If none given,
application name will be used.
service_type: desired type of K8s service. Default value is in line with ServiceSpec's
default value.
additional_labels: Labels to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_selectors: Selectors to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_annotations: Annotations to be added to the kubernetes service.
"""
super().__init__(charm, "kubernetes-service-patch")
self.charm = charm
self.service_name = service_name if service_name else self._app
self.service = self._service_object(
ports,
service_name,
service_type,
additional_labels,
additional_selectors,
additional_annotations,
)

# Make mypy type checking happy that self._patch is a method
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)

def _service_object(
self,
ports: Sequence[PortDefinition],
service_name: str = None,
service_type: ServiceType = "ClusterIP",
additional_labels: dict = None,
additional_selectors: dict = None,
additional_annotations: dict = None,
) -> Service:
"""Creates a valid Service representation.
Args:
ports: a list of tuples of the form (name, port) or (name, port, targetPort)
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,
application name will be used.
service_type: desired type of K8s service. Default value is in line with ServiceSpec's
default value.
additional_labels: Labels to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_selectors: Selectors to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_annotations: Annotations to be added to the kubernetes service.
Returns:
Service: A valid representation of a Kubernetes Service with the correct ports.
"""
if not service_name:
service_name = self._app
labels = {"app.kubernetes.io/name": self._app}
if additional_labels:
labels.update(additional_labels)
selector = {"app.kubernetes.io/name": self._app}
if additional_selectors:
selector.update(additional_selectors)
return Service(
apiVersion="v1",
kind="Service",
metadata=ObjectMeta(
namespace=self._namespace,
name=service_name,
labels=labels,
annotations=additional_annotations, # type: ignore[arg-type]
),
spec=ServiceSpec(
selector=selector,
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,
),
)

def _patch(self, _) -> None:
"""Patch the Kubernetes service created by Juju to map the correct port.
Raises:
PatchFailed: if patching fails due to lack of permissions, or otherwise.
"""
if not self.charm.unit.is_leader():
return

client = Client()
try:
if self.service_name != self._app:
self._delete_and_create_service(client)
client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE)
except ApiError as e:
if e.status.code == 403:
logger.error("Kubernetes service patch failed: `juju trust` this application.")
else:
logger.error("Kubernetes service patch failed: %s", str(e))
else:
logger.info("Kubernetes service '%s' patched successfully", self._app)

def _delete_and_create_service(self, client: Client):
service = client.get(Service, self._app, namespace=self._namespace)
service.metadata.name = self.service_name # type: ignore[attr-defined]
service.metadata.resourceVersion = service.metadata.uid = None # type: ignore[attr-defined] # noqa: E501
client.delete(Service, self._app, namespace=self._namespace)
client.create(service)

def is_patched(self) -> bool:
"""Reports if the service patch has been applied.
Returns:
bool: A boolean indicating if the service patch has been applied.
"""
client = Client()
# Get the relevant service from the cluster
service = client.get(Service, name=self.service_name, namespace=self._namespace)
# Construct a list of expected ports, should the patch be applied
expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports]
# 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
return expected_ports == fetched_ports

@property
def _app(self) -> str:
"""Name of the current Juju application.
Returns:
str: A string containing the name of the current Juju application.
"""
return self.charm.app.name

@property
def _namespace(self) -> str:
"""The Kubernetes namespace we're running in.
Returns:
str: A string containing the name of the current Kubernetes namespace.
"""
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f:
return f.read().strip()
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.

#git+https://github.com/canonical/operator/#egg=ops
git+https://github.com/rbarry82/operator@storage-underscores#egg=ops
git+https://github.com/canonical/operator/#egg=ops
PyYAML
lightkube
lightkube-models
41 changes: 27 additions & 14 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider
from charms.loki_k8s.v0.loki_push_api import LokiPushApiConsumer
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
from charms.prometheus_k8s.v0.prometheus_scrape import PrometheusRulesProvider
from ops.charm import CharmBase
from ops.framework import StoredState
Expand Down Expand Up @@ -77,7 +78,7 @@ class GitSyncLayer(LayerBuilder):
# Since this is an implementation detail, it is captured here as a class variable.
SUBDIR: Final = "repo"

def __init__(self, service_name: str, repo: str, ref: str, wait: int, root: str):
def __init__(self, service_name: str, repo: str, ref: str, wait: int, root: str, port: int):
super().__init__(service_name)
if not repo:
raise LayerConfigError("git-sync config error: invalid repo")
Expand All @@ -90,17 +91,22 @@ def __init__(self, service_name: str, repo: str, ref: str, wait: int, root: str)
self.ref = ref
self.wait = wait
self.root = root
self.port = port

def _command(self) -> str:
cmd = (
"/git-sync "
f"-repo {self.repo} "
f"-branch {self.ref} "
"-depth 1 "
f"-wait {self.wait} "
# "-git-config k:v,k2:v2 "
f"-root {self.root} "
f"-dest {self.SUBDIR}" # so charm code doesn't need to delete
cmd = " ".join(
[
"/git-sync",
f"-repo {self.repo}",
f"-branch {self.ref}",
"-depth 1",
f"-wait {self.wait}",
# "-git-config k:v,k2:v2",
f"-root {self.root}",
f"-dest {self.SUBDIR}", # so charm code doesn't need to delete
f"--http-bind localhost:{self.port}",
"--http-metrics true",
]
)
logger.debug("command: %s", cmd)
return cmd
Expand All @@ -117,6 +123,7 @@ class COSConfigCharm(CharmBase):
_layer_name = "git-sync" # layer label argument for container.add_layer
_service_name = "git-sync" # chosen arbitrarily to match charm name
_peer_relation_name = "replicas" # must match metadata.yaml peer role name
_git_sync_port = 9000 # port number for git-sync's HTTP endpoint

_stored = StoredState()

Expand Down Expand Up @@ -169,6 +176,11 @@ def __init__(self, *args):
),
)

self.service_patcher = KubernetesServicePatch(
self,
[(f"{self.app.name}-git-sync", self._git_sync_port, self._git_sync_port)],
)

def _common_exit_hook(self) -> None:
"""Event processing hook that is common to all events to ensure idempotency."""
if not self.container.can_connect():
Expand Down Expand Up @@ -206,6 +218,7 @@ def _update_layer(self) -> None:
ref=cast(str, self.config.get("git_reference")),
wait=cast(int, self.config.get("git_wait")),
root=self.meta.storages["content-from-git"].location,
port=self._git_sync_port,
).build(startup="disabled")

plan = self.container.get_plan()
Expand All @@ -232,7 +245,7 @@ def _on_upgrade_charm(self, _):
# the config may need update. Calling the common hook to update.
self._common_exit_hook()

def _refresh_config(self):
def _reinitialize(self):
self.prom_rules_provider._reinitialize_alert_rules()
self.loki_rules_provider._reinitialize_alert_rules()
self.grafana_dashboards_provider.reload_dashboards_from_dir()
Expand All @@ -242,11 +255,11 @@ def _on_git_sync_pebble_ready(self, _):
self._common_exit_hook()

# reload rules when git-sync is up after the charm
self._refresh_config()
self._reinitialize()

def _on_update_status(self, _):
# reload rules in lieu of inotify or manual relation-set
self._refresh_config()
self._reinitialize()

def _on_leader_changed(self, _):
"""Event handler for LeaderElected and LeaderSettingsChanged."""
Expand All @@ -266,7 +279,7 @@ def _on_config_changed(self, _):
self._common_exit_hook()

# reload rules when config changes, e.g. branch name
self._refresh_config()
self._reinitialize()

def _restart_service(self) -> None:
"""Helper function for restarting the underlying service."""
Expand Down
Loading

0 comments on commit a4b75dc

Please sign in to comment.