Unpin pydantic for openstack-hypervisor, cinder
Unpin pydantic for openstack-hypervisor, cinder Update cos-agent librrary as latest version uses pydantic 2.x Update cosl package version Change-Id: I90dfa1807222313b22b5c2e40447bf644bb87008
This commit is contained in:
parent
41fc880836
commit
199619e6c5
@ -26,5 +26,5 @@ parts:
|
|||||||
charm-binary-python-packages:
|
charm-binary-python-packages:
|
||||||
- cryptography
|
- cryptography
|
||||||
- jsonschema
|
- jsonschema
|
||||||
- pydantic<2.0
|
- pydantic
|
||||||
- jinja2
|
- jinja2
|
||||||
|
@ -22,4 +22,5 @@ parts:
|
|||||||
charm-binary-python-packages:
|
charm-binary-python-packages:
|
||||||
- cryptography
|
- cryptography
|
||||||
- jsonschema
|
- jsonschema
|
||||||
|
- pydantic
|
||||||
- jinja2
|
- jinja2
|
||||||
|
@ -4,8 +4,8 @@ pyroute2
|
|||||||
netifaces
|
netifaces
|
||||||
jsonschema
|
jsonschema
|
||||||
jinja2
|
jinja2
|
||||||
cosl==0.0.11 ; python_version >= "3.8"
|
cosl==0.0.24
|
||||||
pydantic==1.10.12 ; python_version >= "3.8"
|
pydantic
|
||||||
|
|
||||||
# This charm does not use lightkube* but ops_sunbeam requires it atm
|
# This charm does not use lightkube* but ops_sunbeam requires it atm
|
||||||
lightkube
|
lightkube
|
||||||
|
@ -22,7 +22,7 @@ this charm library.
|
|||||||
Using the `COSAgentProvider` object only requires instantiating it,
|
Using the `COSAgentProvider` object only requires instantiating it,
|
||||||
typically in the `__init__` method of your charm (the one which sends telemetry).
|
typically in the `__init__` method of your charm (the one which sends telemetry).
|
||||||
|
|
||||||
The constructor of `COSAgentProvider` has only one required and nine optional parameters:
|
The constructor of `COSAgentProvider` has only one required and ten optional parameters:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -36,6 +36,7 @@ The constructor of `COSAgentProvider` has only one required and nine optional pa
|
|||||||
log_slots: Optional[List[str]] = None,
|
log_slots: Optional[List[str]] = None,
|
||||||
dashboard_dirs: Optional[List[str]] = None,
|
dashboard_dirs: Optional[List[str]] = None,
|
||||||
refresh_events: Optional[List] = None,
|
refresh_events: Optional[List] = None,
|
||||||
|
tracing_protocols: Optional[List[str]] = None,
|
||||||
scrape_configs: Optional[Union[List[Dict], Callable]] = None,
|
scrape_configs: Optional[Union[List[Dict], Callable]] = None,
|
||||||
):
|
):
|
||||||
```
|
```
|
||||||
@ -65,6 +66,8 @@ The constructor of `COSAgentProvider` has only one required and nine optional pa
|
|||||||
|
|
||||||
- `refresh_events`: List of events on which to refresh relation data.
|
- `refresh_events`: List of events on which to refresh relation data.
|
||||||
|
|
||||||
|
- `tracing_protocols`: List of requested tracing protocols that the charm requires to send traces.
|
||||||
|
|
||||||
- `scrape_configs`: List of standard scrape_configs dicts or a callable that returns the list in
|
- `scrape_configs`: List of standard scrape_configs dicts or a callable that returns the list in
|
||||||
case the configs need to be generated dynamically. The contents of this list will be merged
|
case the configs need to be generated dynamically. The contents of this list will be merged
|
||||||
with the configs from `metrics_endpoints`.
|
with the configs from `metrics_endpoints`.
|
||||||
@ -108,6 +111,7 @@ class TelemetryProviderCharm(CharmBase):
|
|||||||
log_slots=["my-app:slot"],
|
log_slots=["my-app:slot"],
|
||||||
dashboard_dirs=["./src/dashboards_1", "./src/dashboards_2"],
|
dashboard_dirs=["./src/dashboards_1", "./src/dashboards_2"],
|
||||||
refresh_events=["update-status", "upgrade-charm"],
|
refresh_events=["update-status", "upgrade-charm"],
|
||||||
|
tracing_protocols=["otlp_http", "otlp_grpc"],
|
||||||
scrape_configs=[
|
scrape_configs=[
|
||||||
{
|
{
|
||||||
"job_name": "custom_job",
|
"job_name": "custom_job",
|
||||||
@ -206,19 +210,34 @@ class GrafanaAgentMachineCharm(GrafanaAgentCharm)
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, List, Optional, Set, Tuple, Union
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
ClassVar,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Literal,
|
||||||
|
MutableMapping,
|
||||||
|
Optional,
|
||||||
|
Set,
|
||||||
|
Tuple,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from cosl import GrafanaDashboard, JujuTopology
|
from cosl import GrafanaDashboard, JujuTopology
|
||||||
from cosl.rules import AlertRules
|
from cosl.rules import AlertRules
|
||||||
from ops.charm import RelationChangedEvent
|
from ops.charm import RelationChangedEvent
|
||||||
from ops.framework import EventBase, EventSource, Object, ObjectEvents
|
from ops.framework import EventBase, EventSource, Object, ObjectEvents
|
||||||
from ops.model import Relation
|
from ops.model import ModelError, Relation
|
||||||
from ops.testing import CharmType
|
from ops.testing import CharmType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -234,9 +253,9 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
LIBID = "dc15fa84cef84ce58155fb84f6c6213a"
|
LIBID = "dc15fa84cef84ce58155fb84f6c6213a"
|
||||||
LIBAPI = 0
|
LIBAPI = 0
|
||||||
LIBPATCH = 8
|
LIBPATCH = 11
|
||||||
|
|
||||||
PYDEPS = ["cosl", "pydantic < 2"]
|
PYDEPS = ["cosl", "pydantic"]
|
||||||
|
|
||||||
DEFAULT_RELATION_NAME = "cos-agent"
|
DEFAULT_RELATION_NAME = "cos-agent"
|
||||||
DEFAULT_PEER_RELATION_NAME = "peers"
|
DEFAULT_PEER_RELATION_NAME = "peers"
|
||||||
@ -249,7 +268,207 @@ logger = logging.getLogger(__name__)
|
|||||||
SnapEndpoint = namedtuple("SnapEndpoint", "owner, name")
|
SnapEndpoint = namedtuple("SnapEndpoint", "owner, name")
|
||||||
|
|
||||||
|
|
||||||
class CosAgentProviderUnitData(pydantic.BaseModel):
|
# Note: MutableMapping is imported from the typing module and not collections.abc
|
||||||
|
# because subscripting collections.abc.MutableMapping was added in python 3.9, but
|
||||||
|
# most of our charms are based on 20.04, which has python 3.8.
|
||||||
|
|
||||||
|
_RawDatabag = MutableMapping[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class TransportProtocolType(str, enum.Enum):
|
||||||
|
"""Receiver Type."""
|
||||||
|
|
||||||
|
http = "http"
|
||||||
|
grpc = "grpc"
|
||||||
|
|
||||||
|
|
||||||
|
receiver_protocol_to_transport_protocol = {
|
||||||
|
"zipkin": TransportProtocolType.http,
|
||||||
|
"kafka": TransportProtocolType.http,
|
||||||
|
"tempo_http": TransportProtocolType.http,
|
||||||
|
"tempo_grpc": TransportProtocolType.grpc,
|
||||||
|
"otlp_grpc": TransportProtocolType.grpc,
|
||||||
|
"otlp_http": TransportProtocolType.http,
|
||||||
|
"jaeger_thrift_http": TransportProtocolType.http,
|
||||||
|
}
|
||||||
|
|
||||||
|
_tracing_receivers_ports = {
|
||||||
|
# OTLP receiver: see
|
||||||
|
# https://github.com/open-telemetry/opentelemetry-collector/tree/v0.96.0/receiver/otlpreceiver
|
||||||
|
"otlp_http": 4318,
|
||||||
|
"otlp_grpc": 4317,
|
||||||
|
# Jaeger receiver: see
|
||||||
|
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.96.0/receiver/jaegerreceiver
|
||||||
|
"jaeger_grpc": 14250,
|
||||||
|
"jaeger_thrift_http": 14268,
|
||||||
|
# Zipkin receiver: see
|
||||||
|
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.96.0/receiver/zipkinreceiver
|
||||||
|
"zipkin": 9411,
|
||||||
|
}
|
||||||
|
|
||||||
|
ReceiverProtocol = Literal["otlp_grpc", "otlp_http", "zipkin", "jaeger_thrift_http", "jaeger_grpc"]
|
||||||
|
|
||||||
|
|
||||||
|
class TracingError(Exception):
|
||||||
|
"""Base class for custom errors raised by tracing."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotReadyError(TracingError):
|
||||||
|
"""Raised by the provider wrapper if a requirer hasn't published the required data (yet)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolNotRequestedError(TracingError):
|
||||||
|
"""Raised if the user attempts to obtain an endpoint for a protocol it did not request."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataValidationError(TracingError):
|
||||||
|
"""Raised when data validation fails on IPU relation data."""
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiguousRelationUsageError(TracingError):
|
||||||
|
"""Raised when one wrongly assumes that there can only be one relation on an endpoint."""
|
||||||
|
|
||||||
|
|
||||||
|
# TODO we want to eventually use `DatabagModel` from cosl but it likely needs a move to common package first
|
||||||
|
if int(pydantic.version.VERSION.split(".")[0]) < 2: # type: ignore
|
||||||
|
|
||||||
|
class DatabagModel(pydantic.BaseModel): # type: ignore
|
||||||
|
"""Base databag model."""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Pydantic config."""
|
||||||
|
|
||||||
|
# ignore any extra fields in the databag
|
||||||
|
extra = "ignore"
|
||||||
|
"""Ignore any extra fields in the databag."""
|
||||||
|
allow_population_by_field_name = True
|
||||||
|
"""Allow instantiating this class by field name (instead of forcing alias)."""
|
||||||
|
|
||||||
|
_NEST_UNDER = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, databag: MutableMapping):
|
||||||
|
"""Load this model from a Juju databag."""
|
||||||
|
if cls._NEST_UNDER:
|
||||||
|
return cls.parse_obj(json.loads(databag[cls._NEST_UNDER]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
k: json.loads(v)
|
||||||
|
for k, v in databag.items()
|
||||||
|
# Don't attempt to parse model-external values
|
||||||
|
if k in {f.alias for f in cls.__fields__.values()}
|
||||||
|
}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
msg = f"invalid databag contents: expecting json. {databag}"
|
||||||
|
logger.error(msg)
|
||||||
|
raise DataValidationError(msg) from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls.parse_raw(json.dumps(data)) # type: ignore
|
||||||
|
except pydantic.ValidationError as e:
|
||||||
|
msg = f"failed to validate databag: {databag}"
|
||||||
|
logger.debug(msg, exc_info=True)
|
||||||
|
raise DataValidationError(msg) from e
|
||||||
|
|
||||||
|
def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True):
|
||||||
|
"""Write the contents of this model to Juju databag.
|
||||||
|
|
||||||
|
:param databag: the databag to write the data to.
|
||||||
|
:param clear: ensure the databag is cleared before writing it.
|
||||||
|
"""
|
||||||
|
if clear and databag:
|
||||||
|
databag.clear()
|
||||||
|
|
||||||
|
if databag is None:
|
||||||
|
databag = {}
|
||||||
|
|
||||||
|
if self._NEST_UNDER:
|
||||||
|
databag[self._NEST_UNDER] = self.json(by_alias=True)
|
||||||
|
return databag
|
||||||
|
|
||||||
|
dct = self.dict()
|
||||||
|
for key, field in self.__fields__.items(): # type: ignore
|
||||||
|
value = dct[key]
|
||||||
|
databag[field.alias or key] = json.dumps(value)
|
||||||
|
|
||||||
|
return databag
|
||||||
|
|
||||||
|
else:
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
|
class DatabagModel(pydantic.BaseModel):
|
||||||
|
"""Base databag model."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
# ignore any extra fields in the databag
|
||||||
|
extra="ignore",
|
||||||
|
# Allow instantiating this class by field name (instead of forcing alias).
|
||||||
|
populate_by_name=True,
|
||||||
|
# Custom config key: whether to nest the whole datastructure (as json)
|
||||||
|
# under a field or spread it out at the toplevel.
|
||||||
|
_NEST_UNDER=None, # type: ignore
|
||||||
|
arbitrary_types_allowed=True,
|
||||||
|
)
|
||||||
|
"""Pydantic config."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, databag: MutableMapping):
|
||||||
|
"""Load this model from a Juju databag."""
|
||||||
|
nest_under = cls.model_config.get("_NEST_UNDER") # type: ignore
|
||||||
|
if nest_under:
|
||||||
|
return cls.model_validate(json.loads(databag[nest_under])) # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
k: json.loads(v)
|
||||||
|
for k, v in databag.items()
|
||||||
|
# Don't attempt to parse model-external values
|
||||||
|
if k in {(f.alias or n) for n, f in cls.__fields__.items()}
|
||||||
|
}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
msg = f"invalid databag contents: expecting json. {databag}"
|
||||||
|
logger.error(msg)
|
||||||
|
raise DataValidationError(msg) from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls.model_validate_json(json.dumps(data)) # type: ignore
|
||||||
|
except pydantic.ValidationError as e:
|
||||||
|
msg = f"failed to validate databag: {databag}"
|
||||||
|
logger.debug(msg, exc_info=True)
|
||||||
|
raise DataValidationError(msg) from e
|
||||||
|
|
||||||
|
def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True):
|
||||||
|
"""Write the contents of this model to Juju databag.
|
||||||
|
|
||||||
|
:param databag: the databag to write the data to.
|
||||||
|
:param clear: ensure the databag is cleared before writing it.
|
||||||
|
"""
|
||||||
|
if clear and databag:
|
||||||
|
databag.clear()
|
||||||
|
|
||||||
|
if databag is None:
|
||||||
|
databag = {}
|
||||||
|
nest_under = self.model_config.get("_NEST_UNDER")
|
||||||
|
if nest_under:
|
||||||
|
databag[nest_under] = self.model_dump_json( # type: ignore
|
||||||
|
by_alias=True,
|
||||||
|
# skip keys whose values are default
|
||||||
|
exclude_defaults=True,
|
||||||
|
)
|
||||||
|
return databag
|
||||||
|
|
||||||
|
dct = self.model_dump() # type: ignore
|
||||||
|
for key, field in self.model_fields.items(): # type: ignore
|
||||||
|
value = dct[key]
|
||||||
|
if value == field.default:
|
||||||
|
continue
|
||||||
|
databag[field.alias or key] = json.dumps(value)
|
||||||
|
|
||||||
|
return databag
|
||||||
|
|
||||||
|
|
||||||
|
class CosAgentProviderUnitData(DatabagModel):
|
||||||
"""Unit databag model for `cos-agent` relation."""
|
"""Unit databag model for `cos-agent` relation."""
|
||||||
|
|
||||||
# The following entries are the same for all units of the same principal.
|
# The following entries are the same for all units of the same principal.
|
||||||
@ -267,13 +486,16 @@ class CosAgentProviderUnitData(pydantic.BaseModel):
|
|||||||
metrics_scrape_jobs: List[Dict]
|
metrics_scrape_jobs: List[Dict]
|
||||||
log_slots: List[str]
|
log_slots: List[str]
|
||||||
|
|
||||||
|
# Requested tracing protocols.
|
||||||
|
tracing_protocols: Optional[List[str]] = None
|
||||||
|
|
||||||
# when this whole datastructure is dumped into a databag, it will be nested under this key.
|
# when this whole datastructure is dumped into a databag, it will be nested under this key.
|
||||||
# while not strictly necessary (we could have it 'flattened out' into the databag),
|
# while not strictly necessary (we could have it 'flattened out' into the databag),
|
||||||
# this simplifies working with the model.
|
# this simplifies working with the model.
|
||||||
KEY: ClassVar[str] = "config"
|
KEY: ClassVar[str] = "config"
|
||||||
|
|
||||||
|
|
||||||
class CosAgentPeersUnitData(pydantic.BaseModel):
|
class CosAgentPeersUnitData(DatabagModel):
|
||||||
"""Unit databag model for `peers` cos-agent machine charm peer relation."""
|
"""Unit databag model for `peers` cos-agent machine charm peer relation."""
|
||||||
|
|
||||||
# We need the principal unit name and relation metadata to be able to render identifiers
|
# We need the principal unit name and relation metadata to be able to render identifiers
|
||||||
@ -304,6 +526,83 @@ class CosAgentPeersUnitData(pydantic.BaseModel):
|
|||||||
return self.unit_name.split("/")[0]
|
return self.unit_name.split("/")[0]
|
||||||
|
|
||||||
|
|
||||||
|
if int(pydantic.version.VERSION.split(".")[0]) < 2: # type: ignore
|
||||||
|
|
||||||
|
class ProtocolType(pydantic.BaseModel): # type: ignore
|
||||||
|
"""Protocol Type."""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Pydantic config."""
|
||||||
|
|
||||||
|
use_enum_values = True
|
||||||
|
"""Allow serializing enum values."""
|
||||||
|
|
||||||
|
name: str = pydantic.Field(
|
||||||
|
...,
|
||||||
|
description="Receiver protocol name. What protocols are supported (and what they are called) "
|
||||||
|
"may differ per provider.",
|
||||||
|
examples=["otlp_grpc", "otlp_http", "tempo_http"],
|
||||||
|
)
|
||||||
|
|
||||||
|
type: TransportProtocolType = pydantic.Field(
|
||||||
|
...,
|
||||||
|
description="The transport protocol used by this receiver.",
|
||||||
|
examples=["http", "grpc"],
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
class ProtocolType(pydantic.BaseModel):
|
||||||
|
"""Protocol Type."""
|
||||||
|
|
||||||
|
model_config = pydantic.ConfigDict(
|
||||||
|
# Allow serializing enum values.
|
||||||
|
use_enum_values=True
|
||||||
|
)
|
||||||
|
"""Pydantic config."""
|
||||||
|
|
||||||
|
name: str = pydantic.Field(
|
||||||
|
...,
|
||||||
|
description="Receiver protocol name. What protocols are supported (and what they are called) "
|
||||||
|
"may differ per provider.",
|
||||||
|
examples=["otlp_grpc", "otlp_http", "tempo_http"],
|
||||||
|
)
|
||||||
|
|
||||||
|
type: TransportProtocolType = pydantic.Field(
|
||||||
|
...,
|
||||||
|
description="The transport protocol used by this receiver.",
|
||||||
|
examples=["http", "grpc"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Receiver(pydantic.BaseModel):
|
||||||
|
"""Specification of an active receiver."""
|
||||||
|
|
||||||
|
protocol: ProtocolType = pydantic.Field(..., description="Receiver protocol name and type.")
|
||||||
|
url: str = pydantic.Field(
|
||||||
|
...,
|
||||||
|
description="""URL at which the receiver is reachable. If there's an ingress, it would be the external URL.
|
||||||
|
Otherwise, it would be the service's fqdn or internal IP.
|
||||||
|
If the protocol type is grpc, the url will not contain a scheme.""",
|
||||||
|
examples=[
|
||||||
|
"http://traefik_address:2331",
|
||||||
|
"https://traefik_address:2331",
|
||||||
|
"http://tempo_public_ip:2331",
|
||||||
|
"https://tempo_public_ip:2331",
|
||||||
|
"tempo_public_ip:2331",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CosAgentRequirerUnitData(DatabagModel): # noqa: D101
|
||||||
|
"""Application databag model for the COS-agent requirer."""
|
||||||
|
|
||||||
|
receivers: List[Receiver] = pydantic.Field(
|
||||||
|
...,
|
||||||
|
description="List of all receivers enabled on the tracing provider.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class COSAgentProvider(Object):
|
class COSAgentProvider(Object):
|
||||||
"""Integration endpoint wrapper for the provider side of the cos_agent interface."""
|
"""Integration endpoint wrapper for the provider side of the cos_agent interface."""
|
||||||
|
|
||||||
@ -318,6 +617,7 @@ class COSAgentProvider(Object):
|
|||||||
log_slots: Optional[List[str]] = None,
|
log_slots: Optional[List[str]] = None,
|
||||||
dashboard_dirs: Optional[List[str]] = None,
|
dashboard_dirs: Optional[List[str]] = None,
|
||||||
refresh_events: Optional[List] = None,
|
refresh_events: Optional[List] = None,
|
||||||
|
tracing_protocols: Optional[List[str]] = None,
|
||||||
*,
|
*,
|
||||||
scrape_configs: Optional[Union[List[dict], Callable]] = None,
|
scrape_configs: Optional[Union[List[dict], Callable]] = None,
|
||||||
):
|
):
|
||||||
@ -336,6 +636,7 @@ class COSAgentProvider(Object):
|
|||||||
in the form ["snap-name:slot", ...].
|
in the form ["snap-name:slot", ...].
|
||||||
dashboard_dirs: Directory where the dashboards are stored.
|
dashboard_dirs: Directory where the dashboards are stored.
|
||||||
refresh_events: List of events on which to refresh relation data.
|
refresh_events: List of events on which to refresh relation data.
|
||||||
|
tracing_protocols: List of protocols that the charm will be using for sending traces.
|
||||||
scrape_configs: List of standard scrape_configs dicts or a callable
|
scrape_configs: List of standard scrape_configs dicts or a callable
|
||||||
that returns the list in case the configs need to be generated dynamically.
|
that returns the list in case the configs need to be generated dynamically.
|
||||||
The contents of this list will be merged with the contents of `metrics_endpoints`.
|
The contents of this list will be merged with the contents of `metrics_endpoints`.
|
||||||
@ -353,6 +654,8 @@ class COSAgentProvider(Object):
|
|||||||
self._log_slots = log_slots or []
|
self._log_slots = log_slots or []
|
||||||
self._dashboard_dirs = dashboard_dirs
|
self._dashboard_dirs = dashboard_dirs
|
||||||
self._refresh_events = refresh_events or [self._charm.on.config_changed]
|
self._refresh_events = refresh_events or [self._charm.on.config_changed]
|
||||||
|
self._tracing_protocols = tracing_protocols
|
||||||
|
self._is_single_endpoint = charm.meta.relations[relation_name].limit == 1
|
||||||
|
|
||||||
events = self._charm.on[relation_name]
|
events = self._charm.on[relation_name]
|
||||||
self.framework.observe(events.relation_joined, self._on_refresh)
|
self.framework.observe(events.relation_joined, self._on_refresh)
|
||||||
@ -377,6 +680,7 @@ class COSAgentProvider(Object):
|
|||||||
dashboards=self._dashboards,
|
dashboards=self._dashboards,
|
||||||
metrics_scrape_jobs=self._scrape_jobs,
|
metrics_scrape_jobs=self._scrape_jobs,
|
||||||
log_slots=self._log_slots,
|
log_slots=self._log_slots,
|
||||||
|
tracing_protocols=self._tracing_protocols,
|
||||||
)
|
)
|
||||||
relation.data[self._charm.unit][data.KEY] = data.json()
|
relation.data[self._charm.unit][data.KEY] = data.json()
|
||||||
except (
|
except (
|
||||||
@ -441,6 +745,103 @@ class COSAgentProvider(Object):
|
|||||||
dashboards.append(dashboard)
|
dashboards.append(dashboard)
|
||||||
return dashboards
|
return dashboards
|
||||||
|
|
||||||
|
@property
|
||||||
|
def relations(self) -> List[Relation]:
|
||||||
|
"""The tracing relations associated with this endpoint."""
|
||||||
|
return self._charm.model.relations[self._relation_name]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _relation(self) -> Optional[Relation]:
|
||||||
|
"""If this wraps a single endpoint, the relation bound to it, if any."""
|
||||||
|
if not self._is_single_endpoint:
|
||||||
|
objname = type(self).__name__
|
||||||
|
raise AmbiguousRelationUsageError(
|
||||||
|
f"This {objname} wraps a {self._relation_name} endpoint that has "
|
||||||
|
"limit != 1. We can't determine what relation, of the possibly many, you are "
|
||||||
|
f"referring to. Please pass a relation instance while calling {objname}, "
|
||||||
|
"or set limit=1 in the charm metadata."
|
||||||
|
)
|
||||||
|
relations = self.relations
|
||||||
|
return relations[0] if relations else None
|
||||||
|
|
||||||
|
def is_ready(self, relation: Optional[Relation] = None):
|
||||||
|
"""Is this endpoint ready?"""
|
||||||
|
relation = relation or self._relation
|
||||||
|
if not relation:
|
||||||
|
logger.debug(f"no relation on {self._relation_name !r}: tracing not ready")
|
||||||
|
return False
|
||||||
|
if relation.data is None:
|
||||||
|
logger.error(f"relation data is None for {relation}")
|
||||||
|
return False
|
||||||
|
if not relation.app:
|
||||||
|
logger.error(f"{relation} event received but there is no relation.app")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
unit = next(iter(relation.units), None)
|
||||||
|
if not unit:
|
||||||
|
return False
|
||||||
|
databag = dict(relation.data[unit])
|
||||||
|
CosAgentRequirerUnitData.load(databag)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, pydantic.ValidationError, DataValidationError):
|
||||||
|
logger.info(f"failed validating relation data for {relation}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_all_endpoints(
|
||||||
|
self, relation: Optional[Relation] = None
|
||||||
|
) -> Optional[CosAgentRequirerUnitData]:
|
||||||
|
"""Unmarshalled relation data."""
|
||||||
|
relation = relation or self._relation
|
||||||
|
if not relation or not self.is_ready(relation):
|
||||||
|
return None
|
||||||
|
unit = next(iter(relation.units), None)
|
||||||
|
if not unit:
|
||||||
|
return None
|
||||||
|
return CosAgentRequirerUnitData.load(relation.data[unit]) # type: ignore
|
||||||
|
|
||||||
|
def _get_tracing_endpoint(
|
||||||
|
self, relation: Optional[Relation], protocol: ReceiverProtocol
|
||||||
|
) -> Optional[str]:
|
||||||
|
unit_data = self.get_all_endpoints(relation)
|
||||||
|
if not unit_data:
|
||||||
|
return None
|
||||||
|
receivers: List[Receiver] = [i for i in unit_data.receivers if i.protocol.name == protocol]
|
||||||
|
if not receivers:
|
||||||
|
logger.error(f"no receiver found with protocol={protocol!r}")
|
||||||
|
return None
|
||||||
|
if len(receivers) > 1:
|
||||||
|
logger.error(
|
||||||
|
f"too many receivers with protocol={protocol!r}; using first one. Found: {receivers}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
receiver = receivers[0]
|
||||||
|
return receiver.url
|
||||||
|
|
||||||
|
def get_tracing_endpoint(
|
||||||
|
self, protocol: ReceiverProtocol, relation: Optional[Relation] = None
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Receiver endpoint for the given protocol."""
|
||||||
|
endpoint = self._get_tracing_endpoint(relation or self._relation, protocol=protocol)
|
||||||
|
if not endpoint:
|
||||||
|
requested_protocols = set()
|
||||||
|
relations = [relation] if relation else self.relations
|
||||||
|
for relation in relations:
|
||||||
|
try:
|
||||||
|
databag = CosAgentProviderUnitData.load(relation.data[self._charm.unit])
|
||||||
|
except DataValidationError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if databag.tracing_protocols:
|
||||||
|
requested_protocols.update(databag.tracing_protocols)
|
||||||
|
|
||||||
|
if protocol not in requested_protocols:
|
||||||
|
raise ProtocolNotRequestedError(protocol, relation)
|
||||||
|
|
||||||
|
return None
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
class COSAgentDataChanged(EventBase):
|
class COSAgentDataChanged(EventBase):
|
||||||
"""Event emitted by `COSAgentRequirer` when relation data changes."""
|
"""Event emitted by `COSAgentRequirer` when relation data changes."""
|
||||||
@ -554,6 +955,12 @@ class COSAgentRequirer(Object):
|
|||||||
if not (provider_data := self._validated_provider_data(raw)):
|
if not (provider_data := self._validated_provider_data(raw)):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# write enabled receivers to cos-agent relation
|
||||||
|
try:
|
||||||
|
self.update_tracing_receivers()
|
||||||
|
except ModelError:
|
||||||
|
raise
|
||||||
|
|
||||||
# Copy data from the cos_agent relation to the peer relation, so the leader could
|
# Copy data from the cos_agent relation to the peer relation, so the leader could
|
||||||
# follow up.
|
# follow up.
|
||||||
# Save the originating unit name, so it could be used for topology later on by the leader.
|
# Save the originating unit name, so it could be used for topology later on by the leader.
|
||||||
@ -574,6 +981,37 @@ class COSAgentRequirer(Object):
|
|||||||
# need to emit `on.data_changed`), so we're emitting `on.data_changed` either way.
|
# need to emit `on.data_changed`), so we're emitting `on.data_changed` either way.
|
||||||
self.on.data_changed.emit() # pyright: ignore
|
self.on.data_changed.emit() # pyright: ignore
|
||||||
|
|
||||||
|
def update_tracing_receivers(self):
|
||||||
|
"""Updates the list of exposed tracing receivers in all relations."""
|
||||||
|
try:
|
||||||
|
for relation in self._charm.model.relations[self._relation_name]:
|
||||||
|
CosAgentRequirerUnitData(
|
||||||
|
receivers=[
|
||||||
|
Receiver(
|
||||||
|
url=f"{self._get_tracing_receiver_url(protocol)}",
|
||||||
|
protocol=ProtocolType(
|
||||||
|
name=protocol,
|
||||||
|
type=receiver_protocol_to_transport_protocol[protocol],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for protocol in self.requested_tracing_protocols()
|
||||||
|
],
|
||||||
|
).dump(relation.data[self._charm.unit])
|
||||||
|
|
||||||
|
except ModelError as e:
|
||||||
|
# args are bytes
|
||||||
|
msg = e.args[0]
|
||||||
|
if isinstance(msg, bytes):
|
||||||
|
if msg.startswith(
|
||||||
|
b"ERROR cannot read relation application settings: permission denied"
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
f"encountered error {e} while attempting to update_relation_data."
|
||||||
|
f"The relation must be gone."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
def _validated_provider_data(self, raw) -> Optional[CosAgentProviderUnitData]:
|
def _validated_provider_data(self, raw) -> Optional[CosAgentProviderUnitData]:
|
||||||
try:
|
try:
|
||||||
return CosAgentProviderUnitData(**json.loads(raw))
|
return CosAgentProviderUnitData(**json.loads(raw))
|
||||||
@ -586,6 +1024,55 @@ class COSAgentRequirer(Object):
|
|||||||
# FIXME: Figure out what we should do here
|
# FIXME: Figure out what we should do here
|
||||||
self.on.data_changed.emit() # pyright: ignore
|
self.on.data_changed.emit() # pyright: ignore
|
||||||
|
|
||||||
|
def _get_requested_protocols(self, relation: Relation):
|
||||||
|
# Coherence check
|
||||||
|
units = relation.units
|
||||||
|
if len(units) > 1:
|
||||||
|
# should never happen
|
||||||
|
raise ValueError(
|
||||||
|
f"unexpected error: subordinate relation {relation} "
|
||||||
|
f"should have exactly one unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
unit = next(iter(units), None)
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not (raw := relation.data[unit].get(CosAgentProviderUnitData.KEY)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not (provider_data := self._validated_provider_data(raw)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return provider_data.tracing_protocols
|
||||||
|
|
||||||
|
def requested_tracing_protocols(self):
|
||||||
|
"""All receiver protocols that have been requested by our related apps."""
|
||||||
|
requested_protocols = set()
|
||||||
|
for relation in self._charm.model.relations[self._relation_name]:
|
||||||
|
try:
|
||||||
|
protocols = self._get_requested_protocols(relation)
|
||||||
|
except NotReadyError:
|
||||||
|
continue
|
||||||
|
if protocols:
|
||||||
|
requested_protocols.update(protocols)
|
||||||
|
return requested_protocols
|
||||||
|
|
||||||
|
def _get_tracing_receiver_url(self, protocol: str):
|
||||||
|
scheme = "http"
|
||||||
|
try:
|
||||||
|
if self._charm.cert.enabled: # type: ignore
|
||||||
|
scheme = "https"
|
||||||
|
# not only Grafana Agent can implement cos_agent. If the charm doesn't have the `cert` attribute
|
||||||
|
# using our cert_handler, it won't have the `enabled` parameter. In this case, we pass and assume http.
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
# the assumption is that a subordinate charm will always be accessible to its principal charm under its fqdn
|
||||||
|
if receiver_protocol_to_transport_protocol[protocol] == TransportProtocolType.grpc:
|
||||||
|
return f"{socket.getfqdn()}:{_tracing_receivers_ports[protocol]}"
|
||||||
|
return f"{scheme}://{socket.getfqdn()}:{_tracing_receivers_ports[protocol]}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _remote_data(self) -> List[Tuple[CosAgentProviderUnitData, JujuTopology]]:
|
def _remote_data(self) -> List[Tuple[CosAgentProviderUnitData, JujuTopology]]:
|
||||||
"""Return a list of remote data from each of the related units.
|
"""Return a list of remote data from each of the related units.
|
||||||
@ -721,8 +1208,18 @@ class COSAgentRequirer(Object):
|
|||||||
@property
|
@property
|
||||||
def snap_log_endpoints(self) -> List[SnapEndpoint]:
|
def snap_log_endpoints(self) -> List[SnapEndpoint]:
|
||||||
"""Fetch logging endpoints exposed by related snaps."""
|
"""Fetch logging endpoints exposed by related snaps."""
|
||||||
|
endpoints = []
|
||||||
|
endpoints_with_topology = self.snap_log_endpoints_with_topology
|
||||||
|
for endpoint, _ in endpoints_with_topology:
|
||||||
|
endpoints.append(endpoint)
|
||||||
|
|
||||||
|
return endpoints
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_log_endpoints_with_topology(self) -> List[Tuple[SnapEndpoint, JujuTopology]]:
|
||||||
|
"""Fetch logging endpoints and charm topology for each related snap."""
|
||||||
plugs = []
|
plugs = []
|
||||||
for data, _ in self._remote_data:
|
for data, topology in self._remote_data:
|
||||||
targets = data.log_slots
|
targets = data.log_slots
|
||||||
if targets:
|
if targets:
|
||||||
for target in targets:
|
for target in targets:
|
||||||
@ -733,15 +1230,16 @@ class COSAgentRequirer(Object):
|
|||||||
"endpoints; this should not happen."
|
"endpoints; this should not happen."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
plugs.append(target)
|
plugs.append((target, topology))
|
||||||
|
|
||||||
endpoints = []
|
endpoints = []
|
||||||
for plug in plugs:
|
for plug, topology in plugs:
|
||||||
if ":" not in plug:
|
if ":" not in plug:
|
||||||
logger.error(f"invalid plug definition received: {plug}. Ignoring...")
|
logger.error(f"invalid plug definition received: {plug}. Ignoring...")
|
||||||
else:
|
else:
|
||||||
endpoint = SnapEndpoint(*plug.split(":"))
|
endpoint = SnapEndpoint(*plug.split(":"))
|
||||||
endpoints.append(endpoint)
|
endpoints.append((endpoint, topology))
|
||||||
|
|
||||||
return endpoints
|
return endpoints
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -804,3 +1302,67 @@ class COSAgentRequirer(Object):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return dashboards
|
return dashboards
|
||||||
|
|
||||||
|
|
||||||
|
def charm_tracing_config(
|
||||||
|
endpoint_requirer: COSAgentProvider, cert_path: Optional[Union[Path, str]]
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Utility function to determine the charm_tracing config you will likely want.
|
||||||
|
|
||||||
|
If no endpoint is provided:
|
||||||
|
disable charm tracing.
|
||||||
|
If https endpoint is provided but cert_path is not found on disk:
|
||||||
|
disable charm tracing.
|
||||||
|
If https endpoint is provided and cert_path is None:
|
||||||
|
ERROR
|
||||||
|
Else:
|
||||||
|
proceed with charm tracing (with or without tls, as appropriate)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
If you are using charm_tracing >= v1.9:
|
||||||
|
>>> from lib.charms.tempo_k8s.v1.charm_tracing import trace_charm
|
||||||
|
>>> from lib.charms.tempo_k8s.v0.cos_agent import charm_tracing_config
|
||||||
|
>>> @trace_charm(tracing_endpoint="my_endpoint", cert_path="cert_path")
|
||||||
|
>>> class MyCharm(...):
|
||||||
|
>>> _cert_path = "/path/to/cert/on/charm/container.crt"
|
||||||
|
>>> def __init__(self, ...):
|
||||||
|
>>> self.cos_agent = COSAgentProvider(...)
|
||||||
|
>>> self.my_endpoint, self.cert_path = charm_tracing_config(
|
||||||
|
... self.cos_agent, self._cert_path)
|
||||||
|
|
||||||
|
If you are using charm_tracing < v1.9:
|
||||||
|
>>> from lib.charms.tempo_k8s.v1.charm_tracing import trace_charm
|
||||||
|
>>> from lib.charms.tempo_k8s.v2.tracing import charm_tracing_config
|
||||||
|
>>> @trace_charm(tracing_endpoint="my_endpoint", cert_path="cert_path")
|
||||||
|
>>> class MyCharm(...):
|
||||||
|
>>> _cert_path = "/path/to/cert/on/charm/container.crt"
|
||||||
|
>>> def __init__(self, ...):
|
||||||
|
>>> self.cos_agent = COSAgentProvider(...)
|
||||||
|
>>> self.my_endpoint, self.cert_path = charm_tracing_config(
|
||||||
|
... self.cos_agent, self._cert_path)
|
||||||
|
>>> @property
|
||||||
|
>>> def my_endpoint(self):
|
||||||
|
>>> return self._my_endpoint
|
||||||
|
>>> @property
|
||||||
|
>>> def cert_path(self):
|
||||||
|
>>> return self._cert_path
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not endpoint_requirer.is_ready():
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
endpoint = endpoint_requirer.get_tracing_endpoint("otlp_http")
|
||||||
|
if not endpoint:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
is_https = endpoint.startswith("https://")
|
||||||
|
|
||||||
|
if is_https:
|
||||||
|
if cert_path is None:
|
||||||
|
raise TracingError("Cannot send traces to an https endpoint without a certificate.")
|
||||||
|
if not Path(cert_path).exists():
|
||||||
|
# if endpoint is https BUT we don't have a server_cert yet:
|
||||||
|
# disable charm tracing until we do to prevent tls errors
|
||||||
|
return None, None
|
||||||
|
return endpoint, str(cert_path)
|
||||||
|
return endpoint, None
|
||||||
|
Loading…
x
Reference in New Issue
Block a user