diff --git a/lib/charms/loki_k8s/v1/loki_push_api.py b/lib/charms/loki_k8s/v1/loki_push_api.py index 7f8372c47..8ffe3dff3 100644 --- a/lib/charms/loki_k8s/v1/loki_push_api.py +++ b/lib/charms/loki_k8s/v1/loki_push_api.py @@ -480,6 +480,25 @@ def _alert_rules_error(self, event): Units of consumer charm send their alert rules over app relation data using the `alert_rules` key. + +## Charm logging +The `charms.loki_k8s.v0.charm_logging` library can be used in conjunction with this one to configure python's +logging module to forward all logs to Loki via the loki-push-api interface. + +```python +from lib.charms.loki_k8s.v0.charm_logging import log_charm +from lib.charms.loki_k8s.v1.loki_push_api import charm_logging_config, LokiPushApiConsumer + +@log_charm(logging_endpoint="my_endpoints", server_cert="cert_path") +class MyCharm(...): + _cert_path = "/path/to/cert/on/charm/container.crt" + def __init__(self, ...): + self.logging = LokiPushApiConsumer(...) + self.my_endpoints, self.cert_path = charm_logging_config( + self.logging, self._cert_path) +``` + +Do this, and all charm logs will be forwarded to Loki as soon as a relation is formed. """ import json @@ -577,7 +596,11 @@ def _alert_rules_error(self, event): GRPC_LISTEN_PORT_START = 9095 # odd start port -class RelationNotFoundError(ValueError): +class LokiPushApiError(Exception): + """Base class for errors raised by this module.""" + + +class RelationNotFoundError(LokiPushApiError): """Raised if there is no relation with the given name.""" def __init__(self, relation_name: str): @@ -587,7 +610,7 @@ def __init__(self, relation_name: str): super().__init__(self.message) -class RelationInterfaceMismatchError(Exception): +class RelationInterfaceMismatchError(LokiPushApiError): """Raised if the relation with the given name has a different interface.""" def __init__( @@ -607,7 +630,7 @@ def __init__( super().__init__(self.message) -class RelationRoleMismatchError(Exception): +class RelationRoleMismatchError(LokiPushApiError): """Raised if the relation with the given name has a different direction.""" def __init__( @@ -2750,3 +2773,49 @@ def _exec(self, cmd) -> str: result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE) output = result.stdout.decode("utf-8").strip() return output + + +def charm_logging_config( + endpoint_requirer: LokiPushApiConsumer, cert_path: Optional[Union[Path, str]] +) -> Tuple[Optional[List[str]], Optional[str]]: + """Utility function to determine the charm_logging config you will likely want. + + If no endpoint is provided: + disable charm logging. + If https endpoint is provided but cert_path is not found on disk: + disable charm logging. + If https endpoint is provided and cert_path is None: + ERROR + Else: + proceed with charm logging (with or without tls, as appropriate) + + Args: + endpoint_requirer: an instance of LokiPushApiConsumer. + cert_path: a path where a cert is stored. + + Returns: + A tuple with (optionally) the values of the endpoints and the certificate path. + + Raises: + LokiPushApiError: if some endpoint are http and others https. + """ + endpoints = [ep["url"] for ep in endpoint_requirer.loki_endpoints] + if not endpoints: + return None, None + + https = tuple(endpoint.startswith("https://") for endpoint in endpoints) + + if all(https): # all endpoints are https + if cert_path is None: + raise LokiPushApiError("Cannot send logs to https endpoints without a certificate.") + if not Path(cert_path).exists(): + # if endpoints is https BUT we don't have a server_cert yet: + # disable charm logging until we do to prevent tls errors + return None, None + return endpoints, str(cert_path) + + if all(not x for x in https): # all endpoints are http + return endpoints, None + + # if there's a disagreement, that's very weird: + raise LokiPushApiError("Some endpoints are http, some others are https. That's not good.") diff --git a/requirements.txt b/requirements.txt index 55f697c37..8d5e02575 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,10 @@ cryptography # deps: tracing, charm_tracing pydantic + +opentelemetry-api==1.21.0 +opentelemetry-exporter-otlp-proto-common==1.21.0 opentelemetry-exporter-otlp-proto-http==1.21.0 +opentelemetry-proto==1.21.0 +opentelemetry-sdk==1.21.0 +opentelemetry-semantic-conventions==0.42b0 diff --git a/src/charm.py b/src/charm.py index b8fdb58db..32ed2f3c8 100755 --- a/src/charm.py +++ b/src/charm.py @@ -44,7 +44,7 @@ from charms.observability_libs.v1.cert_handler import CertHandler from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider from charms.tempo_k8s.v1.charm_tracing import trace_charm -from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer +from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer, charm_tracing_config from charms.traefik_k8s.v1.ingress_per_unit import IngressPerUnitRequirer from config_builder import ( CERT_FILE, @@ -98,8 +98,8 @@ def to_status(tpl: Tuple[str, str]) -> StatusBase: @trace_charm( - tracing_endpoint="tracing_endpoint", - server_cert="server_ca_cert_path", + tracing_endpoint="_charm_tracing_endpoint", + server_cert="_charm_tracing_ca_cert", extra_types=[ GrafanaDashboardProvider, GrafanaSourceProvider, @@ -109,7 +109,7 @@ def to_status(tpl: Tuple[str, str]) -> StatusBase: MetricsEndpointProvider, ], ) -@log_charm(logging_endpoints="logging_endpoints", server_cert="server_ca_cert_path") +@log_charm(logging_endpoints="_charm_logging_endpoints", server_cert="_charm_logging_ca_cert") class LokiOperatorCharm(CharmBase): """Charm the service.""" @@ -218,6 +218,9 @@ def __init__(self, *args): self.catalogue = CatalogueConsumer(charm=self, item=self._catalogue_item) self.tracing = TracingEndpointRequirer(self, protocols=["otlp_http"]) + self._charm_tracing_endpoint, self._charm_tracing_ca_cert = charm_tracing_config( + self.tracing, self._ca_cert_path + ) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.upgrade_charm, self._on_upgrade_charm) @@ -791,23 +794,16 @@ def _loki_version(self) -> Optional[str]: return result.group(1) @property - def tracing_endpoint(self) -> Optional[str]: - """Tempo endpoint for charm tracing.""" - if self.tracing.is_ready(): - return self.tracing.get_endpoint("otlp_http") - return None - - @property - def logging_endpoints(self) -> List[str]: + def _charm_logging_endpoints(self) -> List[str]: """Loki endpoint for charm logging.""" container = self._loki_container if container.can_connect() and container.get_service(self._name).is_running(): - scheme = "https" if self.server_ca_cert_path else "http" + scheme = "https" if self._charm_logging_ca_cert else "http" return [f"{scheme}://localhost:3100" + self._loki_push_api_endpoint] return [] @property - def server_ca_cert_path(self) -> Optional[str]: + def _charm_logging_ca_cert(self) -> Optional[str]: """Server CA certificate path for TLS tracing.""" if self._tls_ready: return self._ca_cert_path diff --git a/tests/scenario/test_charm_logging.py b/tests/scenario/test_charm_logging.py index 993cfc45c..33a4b740d 100644 --- a/tests/scenario/test_charm_logging.py +++ b/tests/scenario/test_charm_logging.py @@ -27,7 +27,7 @@ def test_no_endpoints_on_loki_not_ready(context, loki_emitter): with context.manager("update-status", state) as mgr: charm = mgr.charm - assert charm.logging_endpoints == [] + assert charm._charm_logging_endpoints == [] logging.getLogger("foo").debug("bar") loki_emitter.assert_not_called() @@ -48,7 +48,7 @@ def test_endpoints_on_loki_ready(context, loki_emitter): with context.manager("update-status", state) as mgr: charm = mgr.charm - assert charm.logging_endpoints == ["http://localhost:3100/loki/api/v1/push"] + assert charm._charm_logging_endpoints == ["http://localhost:3100/loki/api/v1/push"] logging.getLogger("foo").debug("bar") loki_emitter.assert_called() @@ -60,7 +60,7 @@ def test_endpoints_on_loki_ready(context, loki_emitter): assert record.name == "foo" -@patch("charm.LokiOperatorCharm.server_ca_cert_path", new_callable=lambda *_: True) +@patch("charm.LokiOperatorCharm._charm_logging_ca_cert", new_callable=lambda *_: True) def test_endpoints_on_loki_ready_tls(_, context, loki_emitter): state = scenario.State( containers=[ @@ -76,7 +76,7 @@ def test_endpoints_on_loki_ready_tls(_, context, loki_emitter): with context.manager("update-status", state) as mgr: charm = mgr.charm - assert charm.logging_endpoints == ["https://localhost:3100/loki/api/v1/push"] + assert charm._charm_logging_endpoints == ["https://localhost:3100/loki/api/v1/push"] logging.getLogger("foo").debug("bar") loki_emitter.assert_called() diff --git a/tests/scenario/test_charm_logging_config.py b/tests/scenario/test_charm_logging_config.py new file mode 100644 index 000000000..c7f9fdda3 --- /dev/null +++ b/tests/scenario/test_charm_logging_config.py @@ -0,0 +1,79 @@ +from unittest.mock import MagicMock + +import pytest +from charms.loki_k8s.v1.loki_push_api import LokiPushApiError, charm_logging_config + + +def test_charm_logging_config_http(): + # GIVEN endpoints are http + raw_eps = ["http://foo.com", "http://woo.com"] + eps = [{"url": url} for url in raw_eps] + + lpa = MagicMock() + lpa.loki_endpoints = eps + # AND we don't have a cert (tls not supported) + endpoints, cert = charm_logging_config(lpa, None) + + # enable charm logging (http) + assert endpoints == raw_eps + assert cert is None + + +def test_charm_logging_config_https_tls_ready(tmp_path): + # GIVEN endpoints are https + raw_eps = ["https://foo.com", "https://woo.com"] + eps = [{"url": url} for url in raw_eps] + cert_path = tmp_path / "foo.crt" + # AND cert file exists + cert_path.write_text("hello cert") + + lpa = MagicMock() + lpa.loki_endpoints = eps + endpoints, cert = charm_logging_config(lpa, cert_path) + + # enable charm logging (https) + assert endpoints == raw_eps + assert cert == str(cert_path) + + +def test_charm_logging_config_https_tls_not_ready(tmp_path): + # GIVEN endpoints are https + raw_eps = ["https://foo.com", "https://woo.com"] + eps = [{"url": url} for url in raw_eps] + # BUT cert file does not exist + cert_path = tmp_path / "foo.crt" + + lpa = MagicMock() + lpa.loki_endpoints = eps + endpoints, cert = charm_logging_config(lpa, cert_path) + + # disable charm logging + assert endpoints is None + assert cert is None + + +def test_charm_logging_config_https_tls_not_impl(tmp_path): + # GIVEN endpoints are https + raw_eps = ["https://foo.com", "https://woo.com"] + eps = [{"url": url} for url in raw_eps] + # AND we don't even pretend there's a cert + + lpa = MagicMock() + lpa.loki_endpoints = eps + with pytest.raises(LokiPushApiError): + charm_logging_config(lpa, None) + + +def test_charm_logging_config_https_http_mix(tmp_path): + # GIVEN endpoints are a mix of http and https + raw_eps = ["https://foo.com", "http://woo.com"] + eps = [{"url": url} for url in raw_eps] + lpa = MagicMock() + lpa.loki_endpoints = eps + + # we get an error whether we pass a cert or not + with pytest.raises(LokiPushApiError): + charm_logging_config(lpa, "/foo/bar.crt") + + with pytest.raises(LokiPushApiError): + charm_logging_config(lpa, None)