Skip to content

Commit

Permalink
chore: update charm libraries
Browse files Browse the repository at this point in the history
  • Loading branch information
Github Actions committed Feb 7, 2024
1 parent 2435c46 commit c2e212e
Show file tree
Hide file tree
Showing 6 changed files with 709 additions and 178 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def _on_certificate_removed(self, event: CertificateRemovedEvent):
import logging
from typing import List

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

Expand All @@ -109,7 +109,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 = 4
LIBPATCH = 5

PYDEPS = ["jsonschema"]

Expand Down
270 changes: 266 additions & 4 deletions lib/charms/loki_k8s/v1/loki_push_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
send telemetry, such as logs, to Loki through a Log Proxy by implementing the consumer side of the
`loki_push_api` relation interface.
- `LogForwarder`: This object can be used by any Charmed Operator which needs to send the workload
standard output (stdout) through Pebble's log forwarding mechanism, to Loki endpoints through the
`loki_push_api` relation interface.
Filtering logs in Loki is largely performed on the basis of labels. In the Juju ecosystem, Juju
topology labels are used to uniquely identify the workload which generates telemetry like logs.
Expand Down Expand Up @@ -57,7 +61,7 @@
Subsequently, a Loki charm may instantiate the `LokiPushApiProvider` in its constructor as
follows:
from charms.loki_k8s.v0.loki_push_api import LokiPushApiProvider
from charms.loki_k8s.v1.loki_push_api import LokiPushApiProvider
from loki_server import LokiServer
...
Expand Down Expand Up @@ -163,7 +167,7 @@ def __init__(self, *args):
sends logs).
```python
from charms.loki_k8s.v0.loki_push_api import LokiPushApiConsumer
from charms.loki_k8s.v1.loki_push_api import LokiPushApiConsumer
class LokiClientCharm(CharmBase):
Expand Down Expand Up @@ -349,6 +353,45 @@ def _promtail_error(self, event):
)
```
## LogForwarder class Usage
Let's say that we have a charm's workload that writes logs to the standard output (stdout),
and we need to send those logs to a workload implementing the `loki_push_api` interface,
such as `Loki` or `Grafana Agent`. To know how to reach a Loki instance, a charm would
typically use the `loki_push_api` interface.
Use the `LogForwarder` class by instantiating it in the `__init__` method of the charm:
```python
from charms.loki_k8s.v1.loki_push_api import LogForwarder
...
def __init__(self, *args):
...
self._log_forwarder = LogForwarder(
self,
relation_name="logging" # optional, defaults to `logging`
)
```
The `LogForwarder` by default will observe relation events on the `logging` endpoint and
enable/disable log forwarding automatically.
Next, modify the `metadata.yaml` file to add:
The `log-forwarding` relation in the `requires` section:
```yaml
requires:
logging:
interface: loki_push_api
optional: true
```
Once the LogForwader class is implemented in your charm and the relation (implementing the
`loki_push_api` interface) is active and healthy, the library will inject a Pebble layer in
each workload container the charm has access to, to configure Pebble's log forwarding
feature and start sending logs to Loki.
## Alerting Rules
This charm library also supports gathering alerting rules from all related Loki client
Expand Down Expand Up @@ -463,6 +506,7 @@ def _alert_rules_error(self, event):
WorkloadEvent,
)
from ops.framework import EventBase, EventSource, Object, ObjectEvents
from ops.jujuversion import JujuVersion
from ops.model import Container, ModelError, Relation
from ops.pebble import APIError, ChangeError, Layer, PathError, ProtocolError

Expand All @@ -474,7 +518,7 @@ def _alert_rules_error(self, event):

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

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -2045,7 +2089,21 @@ def _download_and_push_promtail_to_workload(
- "binsha": sha256 sum of unpacked promtail binary
container: container into which promtail is to be uploaded.
"""
with request.urlopen(promtail_info["url"]) as r:
# Check for Juju proxy variables and fall back to standard ones if not set
proxies: Optional[Dict[str, str]] = {}
if proxies and os.environ.get("JUJU_CHARM_HTTP_PROXY"):
proxies.update({"http": os.environ["JUJU_CHARM_HTTP_PROXY"]})
if proxies and os.environ.get("JUJU_CHARM_HTTPS_PROXY"):
proxies.update({"https": os.environ["JUJU_CHARM_HTTPS_PROXY"]})
if proxies and os.environ.get("JUJU_CHARM_NO_PROXY"):
proxies.update({"no_proxy": os.environ["JUJU_CHARM_NO_PROXY"]})
else:
proxies = None

proxy_handler = request.ProxyHandler(proxies)
opener = request.build_opener(proxy_handler)

with opener.open(promtail_info["url"]) as r:
file_bytes = r.read()
file_path = os.path.join(BINARY_DIR, promtail_info["filename"] + ".gz")
with open(file_path, "wb") as f:
Expand Down Expand Up @@ -2313,6 +2371,210 @@ def _containers(self) -> Dict[str, Container]:
return {cont: self._charm.unit.get_container(cont) for cont in self._logs_scheme.keys()}


class _PebbleLogClient:
@staticmethod
def check_juju_version() -> bool:
"""Make sure the Juju version supports Log Forwarding."""
juju_version = JujuVersion.from_environ()
if not juju_version > JujuVersion(version=str("3.3")):
msg = f"Juju version {juju_version} does not support Pebble log forwarding. Juju >= 3.4 is needed."
logger.warning(msg)
return False
return True

@staticmethod
def _build_log_target(
unit_name: str, loki_endpoint: str, topology: JujuTopology, enable: bool
) -> Dict:
"""Build a log target for the log forwarding Pebble layer.
Log target's syntax for enabling/disabling forwarding is explained here:
https://github.com/canonical/pebble?tab=readme-ov-file#log-forwarding
"""
services_value = ["all"] if enable else ["-all"]

log_target = {
"override": "replace",
"services": services_value,
"type": "loki",
"location": loki_endpoint,
}
if enable:
log_target.update(
{
"labels": {
"product": "Juju",
"charm": topology._charm_name,
"juju_model": topology._model,
"juju_model_uuid": topology._model_uuid,
"juju_application": topology._application,
"juju_unit": topology._unit,
},
}
)

return {unit_name: log_target}

@staticmethod
def _build_log_targets(
loki_endpoints: Optional[Dict[str, str]], topology: JujuTopology, enable: bool
):
"""Build all the targets for the log forwarding Pebble layer."""
targets = {}
if not loki_endpoints:
return targets

for unit_name, endpoint in loki_endpoints.items():
targets.update(
_PebbleLogClient._build_log_target(
unit_name=unit_name,
loki_endpoint=endpoint,
topology=topology,
enable=enable,
)
)
return targets

@staticmethod
def disable_inactive_endpoints(
container: Container, active_endpoints: Dict[str, str], topology: JujuTopology
):
"""Disable forwarding for inactive endpoints by checking against the Pebble plan."""
pebble_layer = container.get_plan().to_dict().get("log-targets", None)
if not pebble_layer:
return

for unit_name, target in pebble_layer.items():
# If the layer is a disabled log forwarding endpoint, skip it
if "-all" in target["services"]: # pyright: ignore
continue

if unit_name not in active_endpoints:
layer = Layer(
{ # pyright: ignore
"log-targets": _PebbleLogClient._build_log_targets(
loki_endpoints={unit_name: "(removed)"},
topology=topology,
enable=False,
)
}
)
container.add_layer(f"{container.name}-log-forwarding", layer=layer, combine=True)

@staticmethod
def enable_endpoints(
container: Container, active_endpoints: Dict[str, str], topology: JujuTopology
):
"""Enable forwarding for the specified Loki endpoints."""
layer = Layer(
{ # pyright: ignore
"log-targets": _PebbleLogClient._build_log_targets(
loki_endpoints=active_endpoints,
topology=topology,
enable=True,
)
}
)
container.add_layer(f"{container.name}-log-forwarding", layer, combine=True)


class LogForwarder(ConsumerBase):
"""Forward the standard outputs of all workloads operated by a charm to one or multiple Loki endpoints."""

def __init__(
self,
charm: CharmBase,
*,
relation_name: str = DEFAULT_RELATION_NAME,
alert_rules_path: str = DEFAULT_ALERT_RULES_RELATIVE_PATH,
recursive: bool = True,
skip_alert_topology_labeling: bool = False,
):
_PebbleLogClient.check_juju_version()
super().__init__(
charm, relation_name, alert_rules_path, recursive, skip_alert_topology_labeling
)
self._charm = charm
self._relation_name = relation_name

on = self._charm.on[self._relation_name]
self.framework.observe(on.relation_joined, self._update_logging)
self.framework.observe(on.relation_changed, self._update_logging)
self.framework.observe(on.relation_departed, self._update_logging)
self.framework.observe(on.relation_broken, self._update_logging)

def _update_logging(self, _):
"""Update the log forwarding to match the active Loki endpoints."""
loki_endpoints = {}

# Get the endpoints from relation data
for relation in self._charm.model.relations[self._relation_name]:
loki_endpoints.update(self._fetch_endpoints(relation))

if not loki_endpoints:
logger.warning("No Loki endpoints available")
return

for container in self._charm.unit.containers.values():
_PebbleLogClient.disable_inactive_endpoints(
container=container,
active_endpoints=loki_endpoints,
topology=self.topology,
)
_PebbleLogClient.enable_endpoints(
container=container, active_endpoints=loki_endpoints, topology=self.topology
)

def is_ready(self, relation: Optional[Relation] = None):
"""Check if the relation is active and healthy."""
if not relation:
relations = self._charm.model.relations[self._relation_name]
if not relations:
return False
return all(self.is_ready(relation) for relation in relations)

try:
if self._extract_urls(relation):
return True
return False
except (KeyError, json.JSONDecodeError):
return False

def _extract_urls(self, relation: Relation) -> Dict[str, str]:
"""Default getter function to extract Loki endpoints from a relation.
Returns:
A dictionary of remote units and the respective Loki endpoint.
{
"loki/0": "http://loki:3100/loki/api/v1/push",
"another-loki/0": "http://another-loki:3100/loki/api/v1/push",
}
"""
endpoints: Dict = {}

for unit in relation.units:
endpoint = relation.data[unit]["endpoint"]
deserialized_endpoint = json.loads(endpoint)
url = deserialized_endpoint["url"]
endpoints[unit.name] = url

return endpoints

def _fetch_endpoints(self, relation: Relation) -> Dict[str, str]:
"""Fetch Loki Push API endpoints from relation data using the endpoints getter."""
endpoints: Dict = {}

if not self.is_ready(relation):
logger.warning(f"The relation '{relation.name}' is not ready yet.")
return endpoints

# if the code gets here, the function won't raise anymore because it's
# also called in is_ready()
endpoints = self._extract_urls(relation)

return endpoints


class CosTool:
"""Uses cos-tool to inject label matchers into alert rule expressions and validate rules."""

Expand Down
Loading

0 comments on commit c2e212e

Please sign in to comment.