diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 8c4e257de..fc1652a42 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -53,7 +53,8 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - run: sudo snap install --classic concierge + - name: Install concierge + run: sudo snap install --classic concierge # .github/integration/concierge-*.yaml mirrors the upstream concierge # preset and adds juju.extra-bootstrap-args: `juju bootstrap` regularly # fails on slow GHA runners with `unable to contact api server after diff --git a/.github/workflows/smoke.yaml b/.github/workflows/smoke.yaml index 744819747..da2cd6883 100644 --- a/.github/workflows/smoke.yaml +++ b/.github/workflows/smoke.yaml @@ -19,7 +19,8 @@ jobs: continue-on-error: ${{ matrix.juju-channel == '4/stable' && matrix.preset == 'microk8s' }} steps: - - run: sudo snap install --classic concierge + - name: Install Concierge + run: sudo snap install --classic concierge - run: > sudo concierge prepare --juju-channel=${{ matrix.juju-channel }} diff --git a/.github/workflows/tracing-upstream-schemas.yaml b/.github/workflows/tracing-upstream-schemas.yaml new file mode 100644 index 000000000..c416c4b90 --- /dev/null +++ b/.github/workflows/tracing-upstream-schemas.yaml @@ -0,0 +1,33 @@ +name: Tracing upstream schema drift + +# Opt-in drift check: compares ``ops_tracing``'s de-pydantic'd dataclasses +# against the canonical/charmlibs schemas at HEAD. Needs network access and +# pydantic, so it's gated to PRs/pushes that touch the tracing tree. + +on: + push: + branches: + - main + paths: + - 'tracing/**' + - '.github/workflows/tracing-upstream-schemas.yaml' + pull_request: + paths: + - 'tracing/**' + - '.github/workflows/tracing-upstream-schemas.yaml' + workflow_dispatch: + +permissions: {} + +jobs: + upstream-schemas: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - run: uv tool install tox --with tox-uv + - run: uv python install 3.12 + - name: Run tracing upstream schema drift check + run: cd tracing && tox -e upstream-schemas diff --git a/ops/charm.py b/ops/charm.py index 73ae27134..01b28cfef 100644 --- a/ops/charm.py +++ b/ops/charm.py @@ -21,6 +21,7 @@ import logging import os import pathlib +import typing import warnings from collections.abc import Mapping from typing import ( @@ -33,6 +34,7 @@ TypedDict, TypeVar, cast, + get_type_hints, ) from . import model @@ -1699,6 +1701,44 @@ def _juju_fields(cls: type[object]) -> dict[str, str]: raise ValueError('Unable to find class fields') +def _coerce_field(tp: Any, value: Any) -> Any: + """Coerce a decoded ``value`` into the dataclass field type ``tp``. + + Used by :meth:`ops.Relation.load` to recursively construct nested + dataclasses and enum values from JSON-decoded relation data. + """ + origin = typing.get_origin(tp) + if origin is not None: + args = typing.get_args(tp) + if origin in (list, tuple) and args: + return [_coerce_field(args[0], v) for v in value] + if origin in (set, frozenset) and args: + return {_coerce_field(args[0], v) for v in value} + # Literal, Union, Optional, Dict, etc.: accept the value as-is. + return value + if isinstance(tp, type): + if dataclasses.is_dataclass(tp): + return _build_dataclass(tp, value) + if issubclass(tp, enum.Enum): + return tp(value) + return value + + +def _build_dataclass(cls: Any, data: Mapping[str, Any]) -> Any: + """Construct dataclass ``cls`` from ``data``, recursively coercing nested fields. + + Raises ``TypeError`` (via the dataclass ``__init__``) if a required field is + missing, and ``ValueError``/``TypeError`` from coercion of malformed values. + """ + hints = get_type_hints(cls) + kwargs: dict[str, Any] = {} + for field in dataclasses.fields(cls): + if field.name not in data: + continue + kwargs[field.name] = _coerce_field(hints[field.name], data[field.name]) + return cls(**kwargs) + + class CharmMeta: """Object containing the metadata for the charm. diff --git a/ops/model.py b/ops/model.py index 92b7d4fb1..7676cfcc6 100644 --- a/ops/model.py +++ b/ops/model.py @@ -1831,6 +1831,14 @@ def _observer(self, event: ops.RelationEvent): data[key] = value elif key in fields: data[fields[key]] = value + # For plain (non-pydantic) dataclass targets, recursively coerce nested + # dataclass / enum / list / set fields. Pydantic handles its own coercion. + if ( + not args + and dataclasses.is_dataclass(cls) + and not getattr(cls, '__is_pydantic_dataclass__', False) + ): + return _charm._build_dataclass(cls, data) return cls(*args, **data) def save( diff --git a/test/integration/test_infra_canary.py b/test/integration/test_infra_canary.py new file mode 100644 index 000000000..8fc9fb6a6 --- /dev/null +++ b/test/integration/test_infra_canary.py @@ -0,0 +1,65 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Canaries for upstream regressions that block tracing integration tests.""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +# This is the tag pinned in conftest.py via the ``nginx-image`` resource for +# tempo-coordinator-k8s; it is also the charm's own ``upstream-source``. +TEMPO_NGINX_IMAGE = 'ubuntu/nginx:1.24-24.04_beta' + + +@pytest.mark.xfail( + strict=True, + reason=( + 'tempo-coordinator-k8s rev 143 crashes in upgrade-charm because ' + 'coordinated_workers/nginx.py:_delete_certificates calls ' + '`update-ca-certificates --fresh` in the nginx workload container, ' + 'and the pinned ubuntu/nginx image does not ship that binary. ' + 'When this xfail unexpectedly passes, the upstream image has been ' + 'fixed and the gated integration tests in test_tracing.py can be ' + 're-enabled.' + ), +) +def test_tempo_nginx_image_ships_update_ca_certificates(): + """When this xpasses, the integration tests in test_tracing.py can run again.""" + docker = shutil.which('docker') or shutil.which('podman') + if docker is None: + pytest.skip('needs docker or podman to pull and inspect the upstream image') + + result = subprocess.run( + [ + docker, + 'run', + '--rm', + '--entrypoint', + '/bin/sh', + TEMPO_NGINX_IMAGE, + '-c', + 'command -v update-ca-certificates', + ], + capture_output=True, + text=True, + timeout=300, + ) + assert result.returncode == 0, ( + f'{TEMPO_NGINX_IMAGE} still lacks update-ca-certificates ' + f'(stdout={result.stdout!r}, stderr={result.stderr!r})' + ) diff --git a/test/integration/test_tracing.py b/test/integration/test_tracing.py index 9707fb988..cb7225346 100644 --- a/test/integration/test_tracing.py +++ b/test/integration/test_tracing.py @@ -13,6 +13,25 @@ # limitations under the License. +"""Integration tests for ops_tracing. + +These tests are currently gated by two upstream problems that prevent the tempo +coordinator from reaching ``active``: + +- canonical/observability-stack#110 — resource patching on newer Juju releases + caused this file's ``test_direct_connection`` and ``test_with_tls`` to be + commented out of ``.github/workflows/integration.yaml``. +- ``tempo-coordinator-k8s`` rev 143 (every ``2/*`` channel) crashes in + ``upgrade-charm`` because ``coordinated_workers/nginx.py:_delete_certificates`` + calls ``update-ca-certificates --fresh`` in the nginx workload container, and + no ``ubuntu/nginx:*`` tag we tested ships that binary. See the canary in + ``test_infra_canary.py`` — when that test xpasses, this gate is likely lifted. + +Until both are resolved, the four tests below (buffer replay, relation churn, +CA rotation, leader-only databag) cannot run end-to-end. They are kept here so +that re-enabling them is a one-line change in CI once the upstream is healthy. +""" + from __future__ import annotations import json @@ -96,6 +115,152 @@ def test_with_tls(build_tracing_charm: Callable[[], str], tracing_juju: jubilant # about. +def test_buffer_replay_before_provider_ready( + build_tracing_charm: Callable[[], str], tracing_juju: jubilant.Juju +): + """Spans emitted before the tracing relation is up are buffered and replayed. + + Exercises ops_tracing._buffer end-to-end: an action fires while no tracing + relation exists, then tempo is integrated, and the buffered span must land. + """ + charm_path = build_tracing_charm() + tracing_juju.deploy(charm_path) + tracing_juju.wait(lambda status: jubilant.all_active(status, 'test-tracing')) + + checkpoint = time.time() + arg_value = 'buffered-arg' + tracing_juju.run('test-tracing/0', 'one', params={'arg': arg_value}) + + tracing_juju.integrate('test-tracing', 'tempo') + tracing_juju.wait(jubilant.all_active) + + with kubectl_port_forward(tracing_juju.model, 'svc/tempo-worker', 3200) as endpoint: + spans = wait_spans( + endpoint, + ready=lambda spans: 'custom trace on any action' in str(spans), + since=checkpoint, + timeout=180, + ) + names = [span['name'] for span in spans] + assert 'custom trace on any action' in names, ( + f'buffered action span never replayed; saw: {names}' + ) + action_span = next(span for span in spans if span['name'] == 'custom trace on any action') + assert arg_value in json.dumps(action_span) + + +def test_relation_churn(build_tracing_charm: Callable[[], str], tracing_juju: jubilant.Juju): + """Removing and re-adding the tracing relation leaves the requirer healthy. + + Guards the new direct-juju-event observation in the Tracing wrapper (no + longer mediated by TracingEndpointRequirer): -broken must tear cleanly + and -joined must re-arm export. + """ + charm_path = build_tracing_charm() + tracing_juju.deploy(charm_path) + tracing_juju.integrate('test-tracing', 'tempo') + tracing_juju.wait(jubilant.all_active) + + tracing_juju.cli('remove-relation', 'test-tracing:charm-tracing', 'tempo:tracing') + # Waiting only for test-tracing races the re-integrate below: test-tracing's + # -relation-departed hook finishes in ~1s, but the relation stays "dying" on + # the controller until tempo has also processed its -broken hook, and + # `juju integrate` refuses "relation ... is dying, but not yet removed + # (already exists)" during that window. Wait for the whole model to settle + # so both sides have torn down before we re-integrate. + tracing_juju.wait(jubilant.all_active) + + tracing_juju.integrate('test-tracing', 'tempo') + tracing_juju.wait(jubilant.all_active) + + checkpoint = time.time() + arg_value = 'post-churn-arg' + tracing_juju.run('test-tracing/0', 'one', params={'arg': arg_value}) + + with kubectl_port_forward(tracing_juju.model, 'svc/tempo-worker', 3200) as endpoint: + spans = wait_spans( + endpoint, + ready=lambda spans: arg_value in json.dumps(spans), + since=checkpoint, + ) + assert 'custom trace on any action' in [span['name'] for span in spans], ( + 'spans did not flow after re-integrating charm-tracing' + ) + + +def test_ca_rotation(build_tracing_charm: Callable[[], str], tracing_juju: jubilant.Juju): + """A CA cert removed mid-life and re-added must continue to be honoured. + + Pins the inlined certificate_transfer read path: -broken must clear the + trusted CA and -changed on re-integration must re-load it without + restarting the requirer. + """ + _xfail_on_k8s_juju4(tracing_juju, JUJU4_K8S_SECRET_RBAC_BUG) + charm_path = build_tracing_charm() + tracing_juju.deploy('self-signed-certificates') + tracing_juju.integrate('tempo:certificates', 'self-signed-certificates') + tracing_juju.wait(jubilant.all_active) + + tracing_juju.deploy(charm_path) + tracing_juju.integrate('test-tracing', 'self-signed-certificates') + tracing_juju.integrate('test-tracing', 'tempo') + tracing_juju.wait(jubilant.all_active) + + # tempo terminates TLS (self-signed-certificates is related to tempo, not + # tempo-worker), so we query the coordinator, not the worker. + with kubectl_port_forward(tracing_juju.model, 'svc/tempo', 3200) as endpoint: + wait_spans(endpoint, ready=lambda spans: 'ops.main' in str(spans), https=True) + + # Rotate the CA on the provider; the requirer must pick up the new cert + # via certificate_transfer -changed without restarting the export pipeline. + tracing_juju.run('self-signed-certificates/0', 'rotate-private-key') + tracing_juju.wait(jubilant.all_active) + + checkpoint = time.time() + arg_value = 'post-rotation-arg' + tracing_juju.run('test-tracing/0', 'one', params={'arg': arg_value}) + + spans = wait_spans( + endpoint, + ready=lambda spans: arg_value in json.dumps(spans), + since=checkpoint, + https=True, + ) + assert 'custom trace on any action' in [span['name'] for span in spans], ( + 'spans did not flow over TLS after CA was rotated' + ) + + +def test_only_leader_writes_requirer_databag( + build_tracing_charm: Callable[[], str], tracing_juju: jubilant.Juju +): + """Followers must not crash, and only the leader writes the requirer databag. + + The conformance suite pins this against the wire model; this test verifies + it survives an actual two-unit Juju deployment with the new Tracing + wrapper driving the relation. + """ + charm_path = build_tracing_charm() + tracing_juju.deploy(charm_path, num_units=2) + tracing_juju.integrate('test-tracing', 'tempo') + # If a non-leader-aware requirer tried to write the app databag on the + # follower unit, Juju would fail the follower's hook and jubilant.all_active + # would time out here — so reaching all_active with two units is itself the + # follower-does-not-crash invariant. The show-unit check below verifies the + # positive: the leader did populate the app databag. + tracing_juju.wait(jubilant.all_active) + + # Show-unit dumps each unit's relation data; the application section under + # the charm-tracing endpoint is what the leader populated. + raw = tracing_juju.cli('show-unit', 'test-tracing/0', '--format=json') + data = json.loads(raw) + relations = data['test-tracing/0']['relation-info'] + charm_tracing = next(r for r in relations if r['endpoint'] == 'charm-tracing') + assert charm_tracing['application-data'], ( + 'leader unit should have populated the charm-tracing app databag' + ) + + def wait_spans( endpoint: tuple[str, int], ready: Callable[[list[dict[str, Any]]], bool], diff --git a/tracing/ops_tracing/__init__.py b/tracing/ops_tracing/__init__.py index 29772d762..b0c40942d 100644 --- a/tracing/ops_tracing/__init__.py +++ b/tracing/ops_tracing/__init__.py @@ -34,22 +34,6 @@ have relations like these. If the names of the relations differ from this recipe, please adjust the code on the rest of this page to your relation names. -.. hint:: - Make sure to include the Rust build packages in your ``charmcraft.yaml``, because - this library depends on ``pydantic-core`` via ``pydantic``. - - .. code-block:: yaml - - parts: - charm: - plugin: charm - source: . - build-packages: - - cargo - - If you're migrating from the ``charm-tracing`` charm lib, this configuration is - likely already in place. - In your charm, add and initialise the ``Tracing`` object.:: import ops diff --git a/tracing/ops_tracing/_api.py b/tracing/ops_tracing/_api.py index a1f6a74c2..846d10776 100644 --- a/tracing/ops_tracing/_api.py +++ b/tracing/ops_tracing/_api.py @@ -16,25 +16,110 @@ from __future__ import annotations +import json import logging import opentelemetry.trace import ops from ._buffer import Destination -from .vendor.charms.certificate_transfer_interface.v1.certificate_transfer import ( - CertificateTransferRequires, -) -from .vendor.charms.tempo_coordinator_k8s.v0.tracing import ( - AmbiguousRelationUsageError, - ProtocolNotRequestedError, - TracingEndpointRequirer, +from ._tracing_models import ( + CertificateTransferProviderAppData, + CertificateTransferProviderUnitDataV0, + CertificateTransferRequirerAppData, + ReceiverProtocol, + TracingProviderAppData, + TracingRequirerAppData, ) logger = logging.getLogger(__name__) tracer = opentelemetry.trace.get_tracer('ops.tracing') +def _read_certificates(relation: ops.Relation) -> set[str] | None: + """Parse the provider's certificates; ``None`` if neither v1 nor v0 parses. + + Reads the v1 app databag first (``certificates`` key). If the app databag + has no certs and the relation has a remote unit, falls back to the v0 unit + databag shape (``ca``/``certificate``/``chain``) a dual v0/v1 provider + publishes when it hasn't seen ``version=1`` from us. + """ + try: + certificates = relation.load(CertificateTransferProviderAppData, relation.app).certificates + except (json.JSONDecodeError, TypeError, ValueError): + certificates = None + + if certificates: + return certificates + + for unit in relation.units: + try: + v0 = relation.load(CertificateTransferProviderUnitDataV0, unit) + except (json.JSONDecodeError, TypeError, ValueError): + continue + if v0.chain: + return set(v0.chain) + return {v0.ca, v0.certificate} + + return certificates + + +def _advertise_ca_version(charm: ops.CharmBase, ca_relation_name: str) -> None: + """Write ``version=1`` to our own app databag on the ca relation (leader only). + + A dual v0/v1 ``certificate_transfer`` provider needs this to publish v1 + (app databag ``certificates``) rather than falling back to v0 (unit + databag ``ca``/``certificate``/``chain``). + """ + if not charm.unit.is_leader(): + return + data = CertificateTransferRequirerAppData() + try: + for relation in charm.model.relations[ca_relation_name]: + relation.save(data, charm.app) + except ops.ModelError as e: + msg = e.args[0] if e.args else b'' + if isinstance(msg, bytes) and msg.startswith( + b'ERROR cannot read relation application settings: permission denied' + ): + logger.error('cannot advertise ca version on %s: %s', ca_relation_name, e) + return + raise + + +def _read_endpoint(relation: ops.Relation, protocol: ReceiverProtocol) -> str | None: + """Return the URL the provider advertises for ``protocol`` on this relation.""" + try: + data = relation.load(TracingProviderAppData, relation.app) + except (json.JSONDecodeError, TypeError, ValueError) as e: + logger.info('failed validating tracing provider databag for %s: %s', relation, e) + return None + for receiver in data.receivers: + if receiver.protocol.name == protocol: + return receiver.url + return None + + +def _request_protocols( + charm: ops.CharmBase, relation_name: str, protocols: list[ReceiverProtocol] +) -> None: + """Publish ``protocols`` to every relation on ``relation_name`` (leader only).""" + if not charm.unit.is_leader(): + return + data = TracingRequirerAppData(receivers=protocols) + try: + for relation in charm.model.relations[relation_name]: + relation.save(data, charm.app) + except ops.ModelError as e: + msg = e.args[0] if e.args else b'' + if isinstance(msg, bytes) and msg.startswith( + b'ERROR cannot read relation application settings: permission denied' + ): + logger.error('cannot request tracing protocols on %s: %s', relation_name, e) + return + raise + + class Tracing(ops.Object): """Initialise the tracing service. @@ -119,17 +204,14 @@ def __init__( f' expected' ) - self._tracing = TracingEndpointRequirer( - self.charm, - tracing_relation_name, - protocols=['otlp_http'], - ) + _request_protocols(self.charm, tracing_relation_name, ['otlp_http']) + tracing_events = self.charm.on[tracing_relation_name] for event in ( self.charm.on.start, self.charm.on.upgrade_charm, - self._tracing.on.endpoint_changed, - self._tracing.on.endpoint_removed, + tracing_events.relation_changed, + tracing_events.relation_broken, ): self.framework.observe(event, self._reconcile) @@ -148,57 +230,53 @@ def __init__( f" 'certificate_transfer' is expected" ) - self._certificate_transfer = CertificateTransferRequires(charm, ca_relation_name) - - for event in ( - self._certificate_transfer.on.certificate_set_updated, - self._certificate_transfer.on.certificates_removed, - ): + ca_events = self.charm.on[ca_relation_name] + self.framework.observe(ca_events.relation_created, self._advertise_ca_version) + for event in (ca_events.relation_changed, ca_events.relation_broken): self.framework.observe(event, self._reconcile) - else: - self._certificate_transfer = None def _reconcile(self, _event: ops.EventBase): dst = self._get_destination() ops.tracing.set_destination(url=dst.url, ca=dst.ca) + def _advertise_ca_version(self, _event: ops.RelationCreatedEvent): + # This handler is only registered when ca_relation_name is set. + if not self.ca_relation_name: + return + _advertise_ca_version(self.charm, self.ca_relation_name) + def _get_destination(self) -> Destination: try: - if not self._tracing.is_ready(): - return Destination(None, None) - - base_url = self._tracing.get_endpoint('otlp_http') - - if not base_url: - return Destination(None, None) + relation = self.model.get_relation(self.tracing_relation_name) + except ops.TooManyRelatedAppsError: + # Shouldn't happen — the docs require limit=1 on the tracing relation. + logger.exception('multiple tracing relations on %s', self.tracing_relation_name) + return Destination(None, None) + if not relation: + return Destination(None, None) - if not base_url.startswith(('http://', 'https://')): - logger.warning('The base_url=%s must be an HTTP or an HTTPS URL', base_url) - return Destination(None, None) + base_url = _read_endpoint(relation, 'otlp_http') + if not base_url: + return Destination(None, None) - url = f'{base_url.rstrip("/")}/v1/traces' + if not base_url.startswith(('http://', 'https://')): + logger.warning('The base_url=%s must be an HTTP or an HTTPS URL', base_url) + return Destination(None, None) - if url.startswith('http://'): - return Destination(url, None) + url = f'{base_url.rstrip("/")}/v1/traces' - if not self._certificate_transfer: - return Destination(url, self.ca_data) + if url.startswith('http://'): + return Destination(url, None) - ca = self._get_ca() - if not ca: - return Destination(None, None) + if not self.ca_relation_name: + return Destination(url, self.ca_data) - return Destination(url, ca) - except ( - ops.TooManyRelatedAppsError, - AmbiguousRelationUsageError, - ProtocolNotRequestedError, - ): - # These should not really happen, as we've set up a single relation - # and requested the protocol explicitly. - logger.exception('Error getting the tracing destination') + ca = self._get_ca() + if not ca: return Destination(None, None) + return Destination(url, ca) + def _get_ca(self) -> str | None: if not self.ca_relation_name: return None @@ -207,13 +285,7 @@ def _get_ca(self) -> str | None: if not ca_rel: return None - if not self._certificate_transfer: - return None - - if not self._certificate_transfer.is_ready(ca_rel): - return None - - ca_list = self._certificate_transfer.get_all_certificates(ca_rel.id) + ca_list = _read_certificates(ca_rel) if not ca_list: return None diff --git a/tracing/ops_tracing/_tracing_models.py b/tracing/ops_tracing/_tracing_models.py new file mode 100644 index 000000000..9084a8b5a --- /dev/null +++ b/tracing/ops_tracing/_tracing_models.py @@ -0,0 +1,101 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Requirer-side models for the ``tracing`` and ``certificate_transfer`` relation interfaces. + +Schemas: +- https://canonical.com/juju/docs/charmlibs/reference/interfaces/tracing/v2/ +- https://canonical.com/juju/docs/charmlibs/reference/interfaces/certificate_transfer/v1/ +""" + +import dataclasses +import enum +from typing import List, Literal, Optional, Set + +# Supported list rationale https://github.com/canonical/tempo-coordinator-k8s-operator/issues/8 +ReceiverProtocol = Literal[ + 'zipkin', + 'otlp_grpc', + 'otlp_http', + 'jaeger_grpc', + 'jaeger_thrift_http', +] + + +class TransportProtocolType(str, enum.Enum): + """Receiver Type.""" + + http = 'http' + grpc = 'grpc' + + +@dataclasses.dataclass(frozen=True) +class ProtocolType: + """Protocol Type.""" + + name: str + """Receiver protocol name. What protocols are supported (and what they are + called) may differ per provider.""" + type: TransportProtocolType + """The transport protocol used by this receiver.""" + + +@dataclasses.dataclass(frozen=True) +class Receiver: + """Specification of an active receiver.""" + + protocol: ProtocolType + """Receiver protocol name and type.""" + url: str + """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.""" + + +@dataclasses.dataclass(frozen=True) +class TracingProviderAppData: + """Application databag model for the tracing provider.""" + + receivers: List[Receiver] + """List of all receivers enabled on the tracing provider.""" + + +@dataclasses.dataclass(frozen=True) +class TracingRequirerAppData: + """Application databag model for the tracing requirer.""" + + receivers: List[ReceiverProtocol] + """Requested receivers.""" + + +@dataclasses.dataclass(frozen=True) +class CertificateTransferProviderAppData: + """Application databag model for the certificate_transfer provider.""" + + certificates: Set[str] + """PEM-encoded certificates and/or CA certificates published by the provider.""" + + +@dataclasses.dataclass(frozen=True) +class CertificateTransferProviderUnitDataV0: + """Unit databag model for the certificate_transfer provider (v0 fallback). + + A v0 provider publishes a single CA plus certificate on the unit databag, + with the full chain under ``chain``. A dual v0/v1 provider falls back to + this shape when the requirer does not advertise ``version=1``. + """ + + ca: str + certificate: str + chain: Optional[List[str]] = None + + +@dataclasses.dataclass(frozen=True) +class CertificateTransferRequirerAppData: + """Application databag model for the certificate_transfer requirer. + + Advertises the interface version we speak so a dual v0/v1 provider knows + to publish v1 (app databag ``certificates``) rather than v0 (unit databag). + """ + + version: int = 1 diff --git a/tracing/ops_tracing/vendor/charms/certificate_transfer_interface/v1/certificate_transfer.py b/tracing/ops_tracing/vendor/charms/certificate_transfer_interface/v1/certificate_transfer.py deleted file mode 100644 index 34549ad75..000000000 --- a/tracing/ops_tracing/vendor/charms/certificate_transfer_interface/v1/certificate_transfer.py +++ /dev/null @@ -1,705 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Library for the certificate_transfer relation. - -This library contains the Requires and Provides classes for handling the -certificate-transfer interface. It supports both v0 and v1 of the interface. - -For requirers, they will set version 1 in their application databag as a hint to -the provider. They will read the databag from the provider first as v1, and fallback -to v0 if the format does not match. - -For providers, they will check the version in the requirer's application databag, -and send v1 if that version is set to 1, otherwise it will default to 0 for backwards -compatibility. - -## Getting Started -From a charm directory, fetch the library using `charmcraft`: - -```shell -charmcraft fetch-lib charms.certificate_transfer_interface.v1.certificate_transfer -``` - -### Provider charm -The provider charm is the charm providing public certificates to another charm that requires them. - -Example: -```python -from ops.charm import CharmBase, RelationJoinedEvent -from ops.main import main - -from lib.charms.certificate_transfer_interface.v1.certificate_transfer import ( - CertificateTransferProvides, -) - -class DummyCertificateTransferProviderCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.certificate_transfer = CertificateTransferProvides(self, "certificates") - self.framework.observe( - self.on.certificates_relation_joined, self._on_certificates_relation_joined - ) - - def _on_certificates_relation_joined(self, event: RelationJoinedEvent): - certificate = "my certificate" - self.certificate_transfer.add_certificates(certificate) - - -if __name__ == "__main__": - main(DummyCertificateTransferProviderCharm) -``` - -### Requirer charm -The requirer charm is the charm requiring certificates from another charm that provides them. - -Example: -```python -import logging - -from ops.charm import CharmBase -from ops.main import main - -from lib.charms.certificate_transfer_interface.v1.certificate_transfer import ( - CertificatesAvailableEvent, - CertificatesRemovedEvent, - CertificateTransferRequires, -) - - -class DummyCertificateTransferRequirerCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.certificate_transfer = CertificateTransferRequires(self, "certificates") - self.framework.observe( - self.certificate_transfer.on.certificate_set_updated, self._on_certificates_available - ) - self.framework.observe( - self.certificate_transfer.on.certificates_removed, self._on_certificates_removed - ) - - def _on_certificates_available(self, event: CertificatesAvailableEvent): - logging.info(event.certificates) - logging.info(event.relation_id) - - def _on_certificates_removed(self, event: CertificatesRemovedEvent): - logging.info(event.relation_id) - - -if __name__ == "__main__": - main(DummyCertificateTransferRequirerCharm) -``` - -You can integrate both charms by running: - -```bash -juju integrate -``` - -""" - -import json -import logging -from typing import Dict, List, MutableMapping, Optional, Set - -import pydantic -from ops import ( - CharmEvents, - EventBase, - EventSource, - Handle, - Relation, - RelationBrokenEvent, - RelationChangedEvent, - RelationCreatedEvent, -) -from ops.charm import CharmBase -from ops.framework import Object - -# The unique Charmhub library identifier, never change it -LIBID = "3785165b24a743f2b0c60de52db25c8b" - -# Increment this major API version when introducing breaking changes -LIBAPI = 1 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 15 - -logger = logging.getLogger(__name__) - -PYDEPS = ["pydantic"] - -IS_PYDANTIC_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2 - - -class TLSCertificatesError(Exception): - """Base class for custom errors raised by this library.""" - - -class DataValidationError(TLSCertificatesError): - """Raised when data validation fails.""" - - -class DatabagModel(pydantic.BaseModel): - """Base databag model. - - Supports both pydantic v1 and v2. - """ - - if IS_PYDANTIC_V1: - - 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 - - model_config = pydantic.ConfigDict( - # tolerate additional keys in 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 - """Pydantic config.""" - - @classmethod - def load(cls, databag: MutableMapping): - """Load this model from a Juju databag.""" - if IS_PYDANTIC_V1: - return cls._load_v1(databag) - nest_under = cls.model_config.get("_NEST_UNDER") - if nest_under: - return cls.model_validate(json.loads(databag[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 or n) for n, f in cls.model_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)) - except pydantic.ValidationError as e: - msg = f"failed to validate databag: {databag}" - logger.debug(msg, exc_info=True) - raise DataValidationError(msg) from e - - @classmethod - def _load_v1(cls, databag: MutableMapping): - """Load implementation for pydantic v1.""" - 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. - - Args: - databag: The databag to write to. - clear: Whether to clear the databag before writing. - - Returns: - MutableMapping: The databag. - """ - if IS_PYDANTIC_V1: - return self._dump_v1(databag, clear) - 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( - by_alias=True, - # skip keys whose values are default - exclude_defaults=True, - ) - return databag - - dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=False) - databag.update({k: json.dumps(v) for k, v in dct.items()}) - return databag - - def _dump_v1(self, databag: Optional[MutableMapping] = None, clear: bool = True): - """Dump implementation for pydantic v1.""" - if clear and databag: - databag.clear() - - if databag is None: - databag = {} - - if self._NEST_UNDER: - databag[self._NEST_UNDER] = self.json(by_alias=True, exclude_defaults=False) - return databag - - dct = json.loads(self.json(by_alias=True, exclude_defaults=False)) - databag.update({k: json.dumps(v) for k, v in dct.items()}) - - return databag - - -class ProviderApplicationData(DatabagModel): - """Provider App databag model.""" - - if int(pydantic.version.VERSION.split(".")[0]) < 2: - certificates: Set[str] = pydantic.Field( - description="The set of certificates that will be transferred to a requirer", - default_factory=set, - ) - else: - certificates: Set[str] = pydantic.Field( - description="The set of certificates that will be transferred to a requirer", - default=set(), - ) - version: int = pydantic.Field( - description="Version of the interface used in this databag", - default=1, - ) - - -class ProviderUnitDataV0(DatabagModel): - """Provider Unit databag v0 model.""" - - ca: str - certificate: str - chain: Optional[List[str]] = None - version: int = pydantic.Field( - description="Version of the interface used in this databag", - default=0, - ) - - -class RequirerApplicationData(DatabagModel): - """Requirer App databag model.""" - - version: int = pydantic.Field( - description="Version of the interface supported by this requirer", - default=1, - ) - - -class CertificateTransferProvides(Object): - """Certificate Transfer provider class to be instantiated by charms sending certificates.""" - - def __init__(self, charm: CharmBase, relationship_name: str): - super().__init__(charm, relationship_name + "_v1") - self.charm = charm - self.relationship_name = relationship_name - - def add_certificates(self, certificates: Set[str], relation_id: Optional[int] = None) -> None: - """Add certificates from a set to relation data. - - Adds certificate to all relations if relation_id is not provided. - - Args: - certificates (Set[str]): A set of certificate strings in PEM format - relation_id (int): Juju relation ID - - Returns: - None - """ - if not self.charm.unit.is_leader(): - logger.warning("Only the leader unit can add certificates to this relation") - return - relations = self._get_active_relations(relation_id) - if not relations: - if relation_id is not None: - logger.debug( - "At least 1 matching relation ID not found with the relation name '%s'", - self.relationship_name, - ) - else: - logger.debug( - "No active relations found with the relation name '%s'", - self.relationship_name, - ) - return - - for relation in relations: - existing_data = self._get_relation_data(relation) - existing_data.update(certificates) - self._set_relation_data(relation, existing_data) - - def remove_all_certificates(self, relation_id: Optional[int] = None) -> None: - """Remove all certificates from relation data. - - Removes all certificates from all relations if relation_id not given - - Args: - relation_id (int): Relation ID - - Returns: - None - """ - if not self.charm.unit.is_leader(): - logger.warning("Only the leader unit can add certificates to this relation") - return - relations = self._get_active_relations(relation_id) - if not relations: - if relation_id is not None: - logger.debug( - "At least 1 matching relation ID not found with the relation name '%s'", - self.relationship_name, - ) - else: - logger.debug( - "No active relations found with the relation name '%s'", - self.relationship_name, - ) - return - - for relation in relations: - self._set_relation_data(relation, set()) - - def remove_certificate( - self, - certificate: str, - relation_id: Optional[int] = None, - ) -> None: - """Remove a given certificate from relation data. - - Removes certificate from all relations if relation_id not given - - Args: - certificate (str): Certificate in PEM format that's in the list - relation_id (int): Relation ID - - Returns: - None - """ - if not self.charm.unit.is_leader(): - logger.warning("Only the leader unit can add certificates to this relation") - return - relations = self._get_active_relations(relation_id) - if not relations: - if relation_id is not None: - logger.debug( - "At least 1 matching relation ID not found with the relation name '%s'", - self.relationship_name, - ) - else: - logger.debug( - "No active relations found with the relation name '%s'", - self.relationship_name, - ) - return - - for relation in relations: - existing_data = self._get_relation_data(relation) - existing_data.discard(certificate) - self._set_relation_data(relation, existing_data) - - def _get_active_relations(self, relation_id: Optional[int] = None) -> List[Relation]: - """Get the relation if relation_id is given and the relation is active, all active relations otherwise.""" - if relation_id is not None: - relation = self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ) - if relation and relation.active: - return [relation] - return [] - - return [ - relation - for relation in self.model.relations[self.relationship_name] - if relation.active - ] - - def _set_relation_data(self, relation: Relation, data: Set[str]) -> None: - """Set the given relation data.""" - if relation.data.get(relation.app, {}).get("version", "0") == "1": - databag = relation.data[self.model.app] - ProviderApplicationData(certificates=data).dump(databag, True) - else: - if "version" in relation.data.get(relation.app, {}): - logger.warning( - ( - "Requirer in relation %d is using version %s of the interface,", - "defaulting to version 0.", - "This is deprecated, please consider upgrading the requirer", - "to version 1 of the library.", - ), - relation.id, - relation.data[relation.app]["version"], - ) - else: - logger.warning( - ( - "Requirer in relation %d did not provide version field,", - "defaulting to version 0.", - "This is deprecated, please consider upgrading the requirer", - "to version 1 of the library.", - ), - relation.id, - ) - - databag = relation.data[self.model.unit] - if data: - certificates = list(data) - ProviderUnitDataV0( - ca=certificates[0], certificate=certificates[0], chain=certificates - ).dump(databag, True) - - def _get_relation_data(self, relation: Relation) -> Set[str]: - """Get the given relation data.""" - try: - if relation.data.get(relation.app, {}).get("version", "0") == "1": - databag = relation.data[self.model.app] - return ProviderApplicationData().load(databag).certificates - else: - databag = relation.data[self.model.unit] - certs = ProviderUnitDataV0.load(databag).chain - if certs is None: - return set() - return set(certs) - except DataValidationError as e: - logger.error( - ( - "Error parsing relation databag: %s. ", - "Make sure not to interact with the databags " - "except using the public methods in the provider library " - "and use version V1.", - ), - e.args, - ) - return set() - - -class CertificatesAvailableEvent(EventBase): - """Charm Event triggered when the set of provided certificates is updated.""" - - def __init__( - self, - handle: Handle, - certificates: Set[str], - relation_id: int, - ): - super().__init__(handle) - self.certificates = certificates - self.relation_id = relation_id - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "certificates": self.certificates, - "relation_id": self.relation_id, - } - - def restore(self, snapshot: dict): - """Restores snapshot.""" - self.certificates = snapshot["certificates"] - self.relation_id = snapshot["relation_id"] - - -class CertificatesRemovedEvent(EventBase): - """Charm Event triggered when the set of provided certificates is removed.""" - - def __init__(self, handle: Handle, relation_id: int): - super().__init__(handle) - self.relation_id = relation_id - - def snapshot(self) -> dict: - """Return snapshot.""" - return {"relation_id": self.relation_id} - - def restore(self, snapshot: dict): - """Restores snapshot.""" - self.relation_id = snapshot["relation_id"] - - -class CertificateTransferRequirerCharmEvents(CharmEvents): - """List of events that the Certificate Transfer requirer charm can leverage.""" - - certificate_set_updated = EventSource(CertificatesAvailableEvent) - certificates_removed = EventSource(CertificatesRemovedEvent) - - -class CertificateTransferRequires(Object): - """Certificate transfer requirer class to be instantiated by charms expecting certificates.""" - - on = CertificateTransferRequirerCharmEvents() # type: ignore - - def __init__( - self, - charm: CharmBase, - relationship_name: str, - ): - """Observe events related to the relation. - - Args: - charm: Charm object - relationship_name: Juju relation name - """ - # Vendored customisation: prefix the handle with "internal: " so that - # ops_tracing's instance does not collide with a charm that also uses - # this library on the same relation name. - super().__init__(charm, f"internal: {relationship_name}_v1") - self.relationship_name = relationship_name - self.charm = charm - self.framework.observe( - charm.on[relationship_name].relation_changed, self._on_relation_changed - ) - self.framework.observe( - charm.on[relationship_name].relation_broken, self._on_relation_broken - ) - self.framework.observe( - charm.on[relationship_name].relation_created, self._on_relation_created - ) - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Emit certificate set updated event. - - Args: - event: Juju event - - Returns: - None - """ - remote_unit_relation_data = self.get_all_certificates(event.relation.id) - self.on.certificate_set_updated.emit( - certificates=remote_unit_relation_data, - relation_id=event.relation.id, - ) - - def _on_relation_broken(self, event: RelationBrokenEvent) -> None: - """Handle relation broken event. - - Args: - event: Juju event - - Returns: - None - """ - self.on.certificates_removed.emit(relation_id=event.relation.id) - - def _on_relation_created(self, event: RelationCreatedEvent) -> None: - """Handle relation created event. - - Args: - event: Juju event - - Returns: - None - """ - if not self.model.unit.is_leader(): - logger.debug("Only leader unit sets the version number in the app databag") - return - databag = event.relation.data[self.model.app] - RequirerApplicationData().dump(databag, False) - - def get_all_certificates(self, relation_id: Optional[int] = None) -> Set[str]: - """Get transferred certificates. - - If no relation id is given, certificates from all relations will be - provided in a concatenated list. - - Args: - relation_id: The id of the relation to get the certificates from. - """ - relations = self._get_active_relations(relation_id) - result = set() - for relation in relations: - data = self._get_relation_data(relation) - result = result.union(data) - return result - - def get_all_certificates_by_relation( - self, relation_id: Optional[int] = None - ) -> Dict[int, List[str]]: - """Get a deterministic list of certificates grouped by relation. - - - Grouped by relation_id. - - The list order is sorted lexicographically by PEM content. - - Args: - relation_id: If provided, only certificates for this relation are returned. - - Returns: - Dict where keys are relation IDs and values are ordered lists of PEMs. - """ - relations = self._get_active_relations(relation_id) - result: Dict[int, List[str]] = {} - for relation in relations: - certificates = sorted(self._get_relation_data(relation)) - result[relation.id] = certificates - return result - - def is_ready(self, relation: Relation) -> bool: - """Check if the relation is ready by checking that it has valid relation data.""" - databag = relation.data[relation.app] - try: - ProviderApplicationData().load(databag) - return True - except DataValidationError: - return False - - def _get_relation_data(self, relation: Relation) -> Set[str]: - """Get the given relation data.""" - try: - databag = relation.data[relation.app] - certificates = ProviderApplicationData().load(databag).certificates - if not certificates and relation.units: - databag = relation.data.get(relation.units.pop(), {}) - certs = ProviderUnitDataV0.load(databag).chain - if certs is None: - return set() - return set(certs) - return certificates - except DataValidationError as e: - logger.error( - ( - "Error parsing relation databag: %s. ", - "Make sure not to interact with the databags " - "except using the public methods in the provider library " - "and use version V1.", - ), - e.args, - ) - return set() - - def _get_active_relations(self, relation_id: Optional[int] = None) -> List[Relation]: - """Get the active relation if relation_id is given, all active relations otherwise.""" - if relation_id is not None: - if relation := self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ): - return [relation] - return [ - relation - for relation in self.model.relations[self.relationship_name] - if relation.active - ] diff --git a/tracing/ops_tracing/vendor/charms/tempo_coordinator_k8s/v0/tracing.py b/tracing/ops_tracing/vendor/charms/tempo_coordinator_k8s/v0/tracing.py deleted file mode 100644 index 5e7ecb195..000000000 --- a/tracing/ops_tracing/vendor/charms/tempo_coordinator_k8s/v0/tracing.py +++ /dev/null @@ -1,988 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. -"""## Overview. - -This document explains how to integrate with the Tempo charm for the purpose of pushing traces to a -tracing endpoint provided by Tempo. It also explains how alternative implementations of the Tempo charm -may maintain the same interface and be backward compatible with all currently integrated charms. - -## Requirer Library Usage - -Charms seeking to push traces to Tempo, must do so using the `TracingEndpointRequirer` -object from this charm library. For the simplest use cases, using the `TracingEndpointRequirer` -object only requires instantiating it, typically in the constructor of your charm. The -`TracingEndpointRequirer` constructor requires the name of the relation over which a tracing endpoint - is exposed by the Tempo charm, and a list of protocols it intends to send traces with. - This relation must use the `tracing` interface. - The `TracingEndpointRequirer` object may be instantiated as follows - - from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer - - def __init__(self, *args): - super().__init__(*args) - # ... - self.tracing = TracingEndpointRequirer(self, - protocols=['otlp_grpc', 'otlp_http', 'jaeger_http_thrift'] - ) - # ... - -Note that the first argument (`self`) to `TracingEndpointRequirer` is always a reference to the -parent charm. - -Alternatively to providing the list of requested protocols at init time, the charm can do it at -any point in time by calling the -`TracingEndpointRequirer.request_protocols(*protocol:str, relation:Optional[Relation])` method. -Using this method also allows you to use per-relation protocols. - -Units of requirer charms obtain the tempo endpoint to which they will push their traces by calling -`TracingEndpointRequirer.get_endpoint(protocol: str)`, where `protocol` is, for example: -- `otlp_grpc` -- `otlp_http` -- `zipkin` -- `tempo` - -If the `protocol` is not in the list of protocols that the charm requested at endpoint set-up time, -the library will raise an error. - -We recommend that you scale up your tracing provider and relate it to an ingress so that your tracing requests -go through the ingress and get load balanced across all units. Otherwise, if the provider's leader goes down, your tracing goes down. - -## Provider Library Usage - -The `TracingEndpointProvider` object may be used by charms to manage relations with their -trace sources. For this purposes a Tempo-like charm needs to do two things - -1. Instantiate the `TracingEndpointProvider` object by providing it a -reference to the parent (Tempo) charm and optionally the name of the relation that the Tempo charm -uses to interact with its trace sources. This relation must conform to the `tracing` interface -and it is strongly recommended that this relation be named `tracing` which is its -default value. - -For example a Tempo charm may instantiate the `TracingEndpointProvider` in its constructor as -follows - - from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointProvider - - def __init__(self, *args): - super().__init__(*args) - # ... - self.tracing = TracingEndpointProvider(self) - # ... - - - -""" # noqa: W505 -import enum -import json -import logging -from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Dict, - List, - Literal, - MutableMapping, - Optional, - Sequence, - Tuple, - Union, - cast, -) - -import pydantic -from ops.charm import ( - CharmBase, - CharmEvents, - RelationBrokenEvent, - RelationEvent, - RelationRole, -) -from ops.framework import EventSource, Object -from ops.model import ModelError, Relation -from pydantic import BaseModel, Field - -# The unique Charmhub library identifier, never change it -LIBID = "d2f02b1f8d1244b5989fd55bc3a28943" - -# 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 - -PYDEPS = ["pydantic"] - -logger = logging.getLogger(__name__) - -DEFAULT_RELATION_NAME = "tracing" -RELATION_INTERFACE_NAME = "tracing" - -# Supported list rationale https://github.com/canonical/tempo-coordinator-k8s-operator/issues/8 -ReceiverProtocol = Literal[ - "zipkin", - "otlp_grpc", - "otlp_http", - "jaeger_grpc", - "jaeger_thrift_http", -] - -RawReceiver = Tuple[ReceiverProtocol, str] -# Helper type. A raw receiver is defined as a tuple consisting of the protocol name, and the (external, if available), -# (secured, if available) resolvable server url. - - -BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"} - - -class TransportProtocolType(str, enum.Enum): - """Receiver Type.""" - - http = "http" - grpc = "grpc" - - -receiver_protocol_to_transport_protocol: Dict[ReceiverProtocol, TransportProtocolType] = { - "zipkin": TransportProtocolType.http, - "otlp_grpc": TransportProtocolType.grpc, - "otlp_http": TransportProtocolType.http, - "jaeger_thrift_http": TransportProtocolType.http, - "jaeger_grpc": TransportProtocolType.grpc, -} -# A mapping between telemetry protocols and their corresponding transport protocol. - - -class TracingError(Exception): - """Base class for custom errors raised by this library.""" - - -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.""" - - -if int(pydantic.version.VERSION.split(".")[0]) < 2: - - class DatabagModel(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(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 - ) - """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 - - -# todo use models from charm-relation-interfaces -if int(pydantic.version.VERSION.split(".")[0]) < 2: - - class ProtocolType(BaseModel): # type: ignore - """Protocol Type.""" - - class Config: - """Pydantic config.""" - - use_enum_values = True - """Allow serializing enum values.""" - - name: str = 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 = Field( - ..., - description="The transport protocol used by this receiver.", - examples=["http", "grpc"], - ) - -else: - - class ProtocolType(BaseModel): - """Protocol Type.""" - - model_config = ConfigDict( # type: ignore - # Allow serializing enum values. - use_enum_values=True - ) - """Pydantic config.""" - - name: str = 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 = Field( - ..., - description="The transport protocol used by this receiver.", - examples=["http", "grpc"], - ) - - -class Receiver(BaseModel): - """Specification of an active receiver.""" - - protocol: ProtocolType = Field(..., description="Receiver protocol name and type.") - url: str = 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 TracingProviderAppData(DatabagModel): # noqa: D101 - """Application databag model for the tracing provider.""" - - receivers: List[Receiver] = Field( - ..., - description="List of all receivers enabled on the tracing provider.", - ) - - -class TracingRequirerAppData(DatabagModel): # noqa: D101 - """Application databag model for the tracing requirer.""" - - receivers: List[ReceiverProtocol] - """Requested receivers.""" - - -class _AutoSnapshotEvent(RelationEvent): - __args__: Tuple[str, ...] = () - __optional_kwargs__: Dict[str, Any] = {} - - @classmethod - def __attrs__(cls): - return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) - - def __init__(self, handle, relation, *args, **kwargs): - super().__init__(handle, relation) - - if not len(self.__args__) == len(args): - raise TypeError("expected {} args, got {}".format(len(self.__args__), len(args))) - - for attr, obj in zip(self.__args__, args): - setattr(self, attr, obj) - for attr, default in self.__optional_kwargs__.items(): - obj = kwargs.get(attr, default) - setattr(self, attr, obj) - - def snapshot(self) -> dict: - dct = super().snapshot() - for attr in self.__attrs__(): - obj = getattr(self, attr) - try: - dct[attr] = obj - except ValueError as e: - raise ValueError( - "cannot automagically serialize {}: " - "override this method and do it " - "manually.".format(obj) - ) from e - - return dct - - def restore(self, snapshot: dict) -> None: - super().restore(snapshot) - for attr, obj in snapshot.items(): - setattr(self, attr, obj) - - -class RelationNotFoundError(Exception): - """Raised if no relation with the given name is found.""" - - def __init__(self, relation_name: str): - self.relation_name = relation_name - self.message = "No relation named '{}' found".format(relation_name) - super().__init__(self.message) - - -class RelationInterfaceMismatchError(Exception): - """Raised if the relation with the given name has an unexpected interface.""" - - def __init__( - self, - relation_name: str, - expected_relation_interface: str, - actual_relation_interface: str, - ): - self.relation_name = relation_name - self.expected_relation_interface = expected_relation_interface - self.actual_relation_interface = actual_relation_interface - self.message = ( - "The '{}' relation has '{}' as interface rather than the expected '{}'".format( - relation_name, actual_relation_interface, expected_relation_interface - ) - ) - - super().__init__(self.message) - - -class RelationRoleMismatchError(Exception): - """Raised if the relation with the given name has a different role than expected.""" - - def __init__( - self, - relation_name: str, - expected_relation_role: RelationRole, - actual_relation_role: RelationRole, - ): - self.relation_name = relation_name - self.expected_relation_interface = expected_relation_role - self.actual_relation_role = actual_relation_role - self.message = "The '{}' relation has role '{}' rather than the expected '{}'".format( - relation_name, repr(actual_relation_role), repr(expected_relation_role) - ) - - super().__init__(self.message) - - -def _validate_relation_by_interface_and_direction( - charm: CharmBase, - relation_name: str, - expected_relation_interface: str, - expected_relation_role: RelationRole, -): - """Validate a relation. - - Verifies that the `relation_name` provided: (1) exists in metadata.yaml, - (2) declares as interface the interface name passed as `relation_interface` - and (3) has the right "direction", i.e., it is a relation that `charm` - provides or requires. - - Args: - charm: a `CharmBase` object to scan for the matching relation. - relation_name: the name of the relation to be verified. - expected_relation_interface: the interface name to be matched by the - relation named `relation_name`. - expected_relation_role: whether the `relation_name` must be either - provided or required by `charm`. - - Raises: - RelationNotFoundError: If there is no relation in the charm's metadata.yaml - with the same name as provided via `relation_name` argument. - RelationInterfaceMismatchError: The relation with the same name as provided - via `relation_name` argument does not have the same relation interface - as specified via the `expected_relation_interface` argument. - RelationRoleMismatchError: If the relation with the same name as provided - via `relation_name` argument does not have the same role as specified - via the `expected_relation_role` argument. - """ - if relation_name not in charm.meta.relations: - raise RelationNotFoundError(relation_name) - - relation = charm.meta.relations[relation_name] - - # fixme: why do we need to cast here? - actual_relation_interface = cast(str, relation.interface_name) - - if actual_relation_interface != expected_relation_interface: - raise RelationInterfaceMismatchError( - relation_name, expected_relation_interface, actual_relation_interface - ) - - if expected_relation_role is RelationRole.provides: - if relation_name not in charm.meta.provides: - raise RelationRoleMismatchError( - relation_name, RelationRole.provides, RelationRole.requires - ) - elif expected_relation_role is RelationRole.requires: - if relation_name not in charm.meta.requires: - raise RelationRoleMismatchError( - relation_name, RelationRole.requires, RelationRole.provides - ) - else: - raise TypeError("Unexpected RelationDirection: {}".format(expected_relation_role)) - - -class RequestEvent(RelationEvent): - """Event emitted when a remote requests a tracing endpoint.""" - - @property - def requested_receivers(self) -> List[ReceiverProtocol]: - """List of receiver protocols that have been requested.""" - relation = self.relation - app = relation.app - if not app: - raise NotReadyError("relation.app is None") - - return TracingRequirerAppData.load(relation.data[app]).receivers - - -class BrokenEvent(RelationBrokenEvent): - """Event emitted when a relation on tracing is broken.""" - - -class TracingEndpointProviderEvents(CharmEvents): - """TracingEndpointProvider events.""" - - request = EventSource(RequestEvent) - broken = EventSource(BrokenEvent) - - -class TracingEndpointProvider(Object): - """Class representing a trace receiver service.""" - - on = TracingEndpointProviderEvents() # type: ignore - - def __init__( - self, - charm: CharmBase, - external_url: Optional[str] = None, - relation_name: str = DEFAULT_RELATION_NAME, - ): - """Initialize. - - Args: - charm: a `CharmBase` instance that manages this instance of the Tempo service. - external_url: external address of the node hosting the tempo server, - if an ingress is present. - relation_name: an optional string name of the relation between `charm` - and the Tempo charmed service. The default is "tracing". - - Raises: - RelationNotFoundError: If there is no relation in the charm's metadata.yaml - with the same name as provided via `relation_name` argument. - RelationInterfaceMismatchError: The relation with the same name as provided - via `relation_name` argument does not have the `tracing` relation - interface. - RelationRoleMismatchError: If the relation with the same name as provided - via `relation_name` argument does not have the `RelationRole.requires` - role. - """ - _validate_relation_by_interface_and_direction( - charm, relation_name, RELATION_INTERFACE_NAME, RelationRole.provides - ) - - super().__init__(charm, relation_name + "tracing-provider") - self._charm = charm - self._external_url = external_url - self._relation_name = relation_name - self.framework.observe( - self._charm.on[relation_name].relation_joined, self._on_relation_event - ) - self.framework.observe( - self._charm.on[relation_name].relation_created, self._on_relation_event - ) - self.framework.observe( - self._charm.on[relation_name].relation_changed, self._on_relation_event - ) - self.framework.observe( - self._charm.on[relation_name].relation_broken, self._on_relation_broken_event - ) - - def _on_relation_broken_event(self, e: RelationBrokenEvent): - """Handle relation broken events.""" - self.on.broken.emit(e.relation) - - def _on_relation_event(self, e: RelationEvent): - """Handle relation created/joined/changed events.""" - if self.is_requirer_ready(e.relation): - self.on.request.emit(e.relation) - - def is_requirer_ready(self, relation: Relation): - """Attempt to determine if requirer has already populated app data.""" - try: - self._get_requested_protocols(relation) - except NotReadyError: - return False - return True - - @staticmethod - def _get_requested_protocols(relation: Relation): - app = relation.app - if not app: - raise NotReadyError("relation.app is None") - - try: - databag = TracingRequirerAppData.load(relation.data[app]) - except (json.JSONDecodeError, pydantic.ValidationError, DataValidationError): - logger.info(f"relation {relation} is not ready to talk tracing") - raise NotReadyError() - return databag.receivers - - def requested_protocols(self): - """All receiver protocols that have been requested by our related apps.""" - requested_protocols = set() - for relation in self.relations: - try: - protocols = self._get_requested_protocols(relation) - except NotReadyError: - continue - requested_protocols.update(protocols) - return requested_protocols - - @property - def relations(self) -> List[Relation]: - """All relations active on this endpoint.""" - return self._charm.model.relations[self._relation_name] - - def publish_receivers(self, receivers: Sequence[RawReceiver]): - """Let all requirers know that these receivers are active and listening.""" - if not self._charm.unit.is_leader(): - raise RuntimeError("only leader can do this") - - for relation in self.relations: - try: - TracingProviderAppData( - receivers=[ - Receiver( - url=url, - protocol=ProtocolType( - name=protocol, - type=receiver_protocol_to_transport_protocol[protocol], - ), - ) - for protocol, url in receivers - ], - ).dump(relation.data[self._charm.app]) - - 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." - ) - continue - raise - - -class EndpointRemovedEvent(RelationBrokenEvent): - """Event representing a change in one of the receiver endpoints.""" - - -class EndpointChangedEvent(_AutoSnapshotEvent): - """Event representing a change in one of the receiver endpoints.""" - - __args__ = ("_receivers",) - - if TYPE_CHECKING: - _receivers = [] # type: List[dict] - - @property - def receivers(self) -> List[Receiver]: - """Cast receivers back from dict.""" - return [Receiver(**i) for i in self._receivers] - - -class TracingEndpointRequirerEvents(CharmEvents): - """TracingEndpointRequirer events.""" - - endpoint_changed = EventSource(EndpointChangedEvent) - endpoint_removed = EventSource(EndpointRemovedEvent) - - -class TracingEndpointRequirer(Object): - """A tracing endpoint for Tempo.""" - - on = TracingEndpointRequirerEvents() # type: ignore - - def __init__( - self, - charm: CharmBase, - relation_name: str = DEFAULT_RELATION_NAME, - protocols: Optional[List[ReceiverProtocol]] = None, - ): - """Construct a tracing requirer for a Tempo charm. - - If your application supports pushing traces to a distributed tracing backend, the - `TracingEndpointRequirer` object enables your charm to easily access endpoint information - exchanged over a `tracing` relation interface. - - Args: - charm: a `CharmBase` object that manages this - `TracingEndpointRequirer` object. Typically, this is `self` in the instantiating - class. - relation_name: an optional string name of the relation between `charm` - and the Tempo charmed service. The default is "tracing". It is strongly - advised not to change the default, so that people deploying your charm will have a - consistent experience with all other charms that provide tracing endpoints. - protocols: optional list of protocols that the charm intends to send traces with. - The provider will enable receivers for these and only these protocols, - so be sure to enable all protocols the charm or its workload are going to need. - - Raises: - RelationNotFoundError: If there is no relation in the charm's metadata.yaml - with the same name as provided via `relation_name` argument. - RelationInterfaceMismatchError: The relation with the same name as provided - via `relation_name` argument does not have the `tracing` relation - interface. - RelationRoleMismatchError: If the relation with the same name as provided - via `relation_name` argument does not have the `RelationRole.provides` - role. - """ - _validate_relation_by_interface_and_direction( - charm, relation_name, RELATION_INTERFACE_NAME, RelationRole.requires - ) - - super().__init__(charm, f"internal: {relation_name}") - - self._is_single_endpoint = charm.meta.relations[relation_name].limit == 1 - - self._charm = charm - self._relation_name = relation_name - - events = self._charm.on[self._relation_name] - self.framework.observe(events.relation_changed, self._on_tracing_relation_changed) - self.framework.observe(events.relation_broken, self._on_tracing_relation_broken) - - if protocols: - self.request_protocols(protocols) - - def request_protocols( - self, protocols: Sequence[ReceiverProtocol], relation: Optional[Relation] = None - ): - """Publish the list of protocols which the provider should activate.""" - # todo: should we check if _is_single_endpoint and len(self.relations) > 1 and raise, here? - relations = [relation] if relation else self.relations - - if not protocols: - # empty sequence - raise ValueError( - "You need to pass a nonempty sequence of protocols to `request_protocols`." - ) - - try: - if self._charm.unit.is_leader(): - for relation in relations: - TracingRequirerAppData( - receivers=list(protocols), - ).dump(relation.data[self._charm.app]) - - 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 request_protocols." - f"The relation must be gone." - ) - return - raise - - @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"talking about. 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: - databag = dict(relation.data[relation.app]) - TracingProviderAppData.load(databag) - - except (json.JSONDecodeError, pydantic.ValidationError, DataValidationError): - logger.info(f"failed validating relation data for {relation}") - return False - return True - - def _on_tracing_relation_changed(self, event): - """Notify the providers that there is new endpoint information available.""" - relation = event.relation - if not self.is_ready(relation): - self.on.endpoint_removed.emit(relation) # type: ignore - return - - data = TracingProviderAppData.load(relation.data[relation.app]) - self.on.endpoint_changed.emit(relation, [i.dict() for i in data.receivers]) # type: ignore - - def _on_tracing_relation_broken(self, event: RelationBrokenEvent): - """Notify the providers that the endpoint is broken.""" - relation = event.relation - self.on.endpoint_removed.emit(relation) # type: ignore - - def get_all_endpoints( - self, relation: Optional[Relation] = None - ) -> Optional[TracingProviderAppData]: - """Unmarshalled relation data.""" - relation = relation or self._relation - if not self.is_ready(relation): - return - return TracingProviderAppData.load(relation.data[relation.app]) # type: ignore - - def _get_endpoint( - self, relation: Optional[Relation], protocol: ReceiverProtocol - ) -> Optional[str]: - app_data = self.get_all_endpoints(relation) - if not app_data: - return None - receivers: List[Receiver] = list( - filter(lambda i: i.protocol.name == protocol, app_data.receivers) - ) - if not receivers: - # it can happen if the charm requests tracing protocols, but the relay (such as grafana-agent) isn't yet - # connected to the tracing backend. In this case, it's not an error the charm author can do anything about - logger.warning(f"no receiver found with protocol={protocol!r}.") - return - if len(receivers) > 1: - # if we have more than 1 receiver that matches, it shouldn't matter which receiver we'll be using. - logger.warning( - f"too many receivers with protocol={protocol!r}; using first one. Found: {receivers}" - ) - - receiver = receivers[0] - return receiver.url - - def get_endpoint( - self, protocol: ReceiverProtocol, relation: Optional[Relation] = None - ) -> Optional[str]: - """Receiver endpoint for the given protocol. - - It could happen that this function gets called before the provider publishes the endpoints. - In such a scenario, if a non-leader unit calls this function, a permission denied exception will be raised due to - restricted access. To prevent this, this function needs to be guarded by the `is_ready` check. - - Raises: - ProtocolNotRequestedError: - If the charm unit is the leader unit and attempts to obtain an endpoint for a protocol it did not request. - """ - endpoint = self._get_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 = TracingRequirerAppData.load(relation.data[self._charm.app]) - except DataValidationError: - continue - - requested_protocols.update(databag.receivers) - - if protocol not in requested_protocols: - raise ProtocolNotRequestedError(protocol, relation) - - return None - return endpoint - - -def charm_tracing_config( - endpoint_requirer: TracingEndpointRequirer, cert_path: Optional[Union[Path, str]] -) -> Tuple[Optional[str], Optional[str]]: - """Return the charm_tracing config you 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: - - from lib.charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm - from lib.charms.tempo_coordinator_k8s.v0.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.tracing = TracingEndpointRequirer(...) - self.my_endpoint, self.cert_path = charm_tracing_config( - self.tracing, self._cert_path - ) - """ - if not endpoint_requirer.is_ready(): - return None, None - - endpoint = endpoint_requirer.get_endpoint("otlp_http") - if not endpoint: - return None, None - - is_https = endpoint.startswith("https://") - - if is_https: - if cert_path is None or not Path(cert_path).exists(): - # disable charm tracing until we obtain a cert to prevent tls errors - logger.error( - "Tracing endpoint is https, but no server_cert has been passed." - "Please point @trace_charm to a `server_cert` attr. " - "This might also mean that the tracing provider is related to a " - "certificates provider, but this application is not (yet). " - "In that case, you might just have to wait a bit for the certificates " - "integration to settle. " - ) - return None, None - return endpoint, str(cert_path) - else: - return endpoint, None diff --git a/tracing/pyproject.toml b/tracing/pyproject.toml index aaea44a95..4fe41617c 100644 --- a/tracing/pyproject.toml +++ b/tracing/pyproject.toml @@ -32,7 +32,6 @@ dependencies = [ "opentelemetry-api~=1.0", "opentelemetry-sdk~=1.30", "ops==3.9.0.dev0", - "pydantic", ] [project.urls] diff --git a/tracing/test/conftest.py b/tracing/test/conftest.py index bc2edde5d..dbc5604dc 100644 --- a/tracing/test/conftest.py +++ b/tracing/test/conftest.py @@ -24,6 +24,26 @@ import pytest import ops_tracing +from ops_tracing._tracing_models import TracingProviderAppData + + +@pytest.fixture +def load_provider_app_data(): + """Drive ``Relation.load(TracingProviderAppData, ...)`` against a raw databag.""" + + def _load(databag: dict[str, str]) -> TracingProviderAppData: + ctx = ops.testing.Context( + ops.CharmBase, + meta={'name': 'tester', 'requires': {'charm-tracing': {'interface': 'tracing'}}}, + ) + rel = ops.testing.Relation('charm-tracing', remote_app_data=databag) + state_in = ops.testing.State(leader=True, relations={rel}) + with ctx(ctx.on.relation_changed(rel), state_in) as mgr: + relation = mgr.charm.model.get_relation('charm-tracing') + assert relation is not None and relation.app is not None + return relation.load(TracingProviderAppData, relation.app) + + return _load @pytest.fixture diff --git a/tracing/test/test_api.py b/tracing/test/test_api.py index 7994bcb56..edbd0234f 100644 --- a/tracing/test/test_api.py +++ b/tracing/test/test_api.py @@ -20,10 +20,6 @@ import ops.testing import pytest -_pydantic = pytest.importorskip('pydantic') - -pytestmark = pytest.mark.filterwarnings('ignore::pydantic.PydanticDeprecatedSince20') - def test_charm_runs(sample_charm: type[ops.CharmBase]): ctx = ops.testing.Context(sample_charm) diff --git a/tracing/test/test_certificate_transfer_v1_conformance.py b/tracing/test/test_certificate_transfer_v1_conformance.py new file mode 100644 index 000000000..39bcdc094 --- /dev/null +++ b/tracing/test/test_certificate_transfer_v1_conformance.py @@ -0,0 +1,222 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Conformance tests for the ``certificate_transfer`` v1 interface. + +These tests pin our requirer-side behaviour (the CA branch of the ``Tracing`` +class and ``_read_certificates`` in ``ops_tracing/_api.py``) against the +behaviour the upstream charm relation interface documents at: + + https://canonical.com/juju/docs/charmlibs/reference/interfaces/certificate_transfer/v1/ + +We act as a requirer of ``certificate_transfer``: we consume the provider's +``certificates`` app-databag key and feed those PEMs into the tracing TLS +config. Each test names the verbatim clause from the v1 doc page it covers. +""" + +from __future__ import annotations + +import json +from unittest.mock import Mock + +import ops +import ops.testing +import pytest + + +@pytest.fixture +def mock_destination(monkeypatch: pytest.MonkeyPatch) -> Mock: + rv = Mock() + monkeypatch.setattr(ops.tracing, 'set_destination', rv) + return rv + + +# --------------------------------------------------------------------------- +# Provider-side clauses (we are NOT the provider; these document what a +# conforming counterpart will publish, which our requirer-side reader depends +# on): +# +# "Is expected to provide a list of public certificates and/or CA +# certificates" +# "Is expected to provide the used version of the interface." +# +# The provider publishes them under the ``certificates`` app-databag key as a +# JSON array of PEM strings (per the upstream v1 schema example). +# --------------------------------------------------------------------------- + + +# Requirer clause, verbatim: +# "Is expected to provide 1 as a version number and to use the provided +# certificates and/or CA certificates to authenticate communications." +# +# Our impl honours the "use the provided certificates" half: a https:// +# tracing URL combined with a populated ``certificates`` databag results in +# the CA bundle being threaded through to ``ops.tracing.set_destination``. +def test_requirer_uses_provided_certificates( + sample_charm: type[ops.CharmBase], + mock_destination: Mock, + https_relation: ops.testing.Relation, + ca_relation: ops.testing.Relation, +): + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={https_relation, ca_relation}) + ctx.run(ctx.on.relation_changed(ca_relation), state) + # ca_relation publishes {'FIRST', 'SECOND'} as PEMs; we sort and join to + # build a deterministic CA bundle. + mock_destination.assert_called_with(url='https://tls.example/v1/traces', ca='FIRST\nSECOND') + + +def test_requirer_handles_empty_certificate_set( + sample_charm: type[ops.CharmBase], mock_destination: Mock +): + """Provider hasn't published certificates yet: an https destination is unusable.""" + https_relation = ops.testing.Relation( + 'charm-tracing', + remote_app_data={ + 'receivers': json.dumps([ + { + 'protocol': {'name': 'otlp_http', 'type': 'http'}, + 'url': 'https://tls.example/', + } + ]), + }, + ) + empty_ca = ops.testing.Relation('receive-ca-cert', remote_app_data={}) + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={https_relation, empty_ca}) + ctx.run(ctx.on.relation_changed(empty_ca), state) + mock_destination.assert_called_with(url=None, ca=None) + + +def test_requirer_handles_malformed_certificates_databag( + sample_charm: type[ops.CharmBase], mock_destination: Mock +): + """A provider that publishes a non-JSON ``certificates`` value must not crash us.""" + https_relation = ops.testing.Relation( + 'charm-tracing', + remote_app_data={ + 'receivers': json.dumps([ + { + 'protocol': {'name': 'otlp_http', 'type': 'http'}, + 'url': 'https://tls.example/', + } + ]), + }, + ) + bad_ca = ops.testing.Relation( + 'receive-ca-cert', + remote_app_data={'certificates': 'not-json'}, + ) + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={https_relation, bad_ca}) + # Must not raise; _read_certificates returns None on parse failure. + ctx.run(ctx.on.relation_changed(bad_ca), state) + mock_destination.assert_called_with(url=None, ca=None) + + +def test_requirer_reads_provider_certificates_key( + sample_charm: type[ops.CharmBase], + mock_destination: Mock, + https_relation: ops.testing.Relation, +): + """The provider publishes PEMs under the app-databag key named ``certificates``.""" + # If the upstream key ever renames, `_read_certificates` returns the + # empty-set default and TLS would silently break. This test pins the key. + ca_relation = ops.testing.Relation( + 'receive-ca-cert', + remote_app_data={'certificates': json.dumps(['PEM-A', 'PEM-B', 'PEM-C'])}, + ) + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={https_relation, ca_relation}) + ctx.run(ctx.on.relation_changed(ca_relation), state) + # Sorted-and-joined PEMs are what reaches set_destination. + mock_destination.assert_called_with( + url='https://tls.example/v1/traces', ca='PEM-A\nPEM-B\nPEM-C' + ) + + +def test_requirer_ignores_unknown_provider_keys( + sample_charm: type[ops.CharmBase], + mock_destination: Mock, + https_relation: ops.testing.Relation, +): + """A conforming provider may add a ``version`` field; we must ignore it.""" + ca_relation = ops.testing.Relation( + 'receive-ca-cert', + remote_app_data={ + 'certificates': json.dumps(['ONLY']), + 'version': json.dumps(1), + }, + ) + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={https_relation, ca_relation}) + ctx.run(ctx.on.relation_changed(ca_relation), state) + mock_destination.assert_called_with(url='https://tls.example/v1/traces', ca='ONLY') + + +# Requirer clause, verbatim (version half): +# "Is expected to provide 1 as a version number ..." +# +# A dual v0/v1 provider (LIBPATCH 15+ of the vendored library) uses this to +# decide whether to publish v1 (app databag ``certificates``) or fall back to +# v0 (unit databag ``ca``/``certificate``/``chain``). We write it on +# ``-created`` on the leader only. +def test_requirer_writes_version_on_relation_created( + sample_charm: type[ops.CharmBase], + mock_destination: Mock, +): + ca_relation = ops.testing.Relation('receive-ca-cert') + ctx = ops.testing.Context(sample_charm) + state_in = ops.testing.State(leader=True, relations={ca_relation}) + state_out = ctx.run(ctx.on.relation_created(ca_relation), state_in) + rel_out = state_out.get_relation(ca_relation.id) + assert rel_out.local_app_data == {'version': json.dumps(1)} + + +def test_requirer_follower_does_not_write_version( + sample_charm: type[ops.CharmBase], + mock_destination: Mock, +): + """A follower unit must not attempt to write the app databag (Juju forbids it).""" + ca_relation = ops.testing.Relation('receive-ca-cert') + ctx = ops.testing.Context(sample_charm) + state_in = ops.testing.State(leader=False, relations={ca_relation}) + state_out = ctx.run(ctx.on.relation_created(ca_relation), state_in) + rel_out = state_out.get_relation(ca_relation.id) + assert dict(rel_out.local_app_data) == {} + + +def test_requirer_reads_v0_fallback_from_unit_databag( + sample_charm: type[ops.CharmBase], + mock_destination: Mock, + https_relation: ops.testing.Relation, +): + """A v0 provider publishes ca/certificate/chain on the unit databag; we honour it.""" + ca_relation = ops.testing.Relation( + 'receive-ca-cert', + remote_app_data={}, + remote_units_data={ + 0: { + 'ca': json.dumps('CA-PEM'), + 'certificate': json.dumps('CERT-PEM'), + 'chain': json.dumps(['LEAF', 'INTER', 'ROOT']), + }, + }, + ) + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={https_relation, ca_relation}) + ctx.run(ctx.on.relation_changed(ca_relation), state) + mock_destination.assert_called_with( + url='https://tls.example/v1/traces', ca='INTER\nLEAF\nROOT' + ) diff --git a/tracing/test/test_models.py b/tracing/test/test_models.py new file mode 100644 index 000000000..6cced0023 --- /dev/null +++ b/tracing/test/test_models.py @@ -0,0 +1,62 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Load-path tests for the de-pydantic'd tracing dataclasses. + +Drives ``ops.Relation.load`` end-to-end via ``ops.testing`` so the recursive +dataclass/enum coercion in ops is what gets exercised. +""" + +from __future__ import annotations + +import json + +import pytest + +from ops_tracing._tracing_models import TracingProviderAppData, TransportProtocolType + + +def test_tracing_provider_app_data_from_wire_format(load_provider_app_data): + databag = { + 'receivers': json.dumps([ + { + 'protocol': {'name': 'otlp_http', 'type': 'http'}, + 'url': 'http://tracing.example:4318/', + } + ]) + } + loaded = load_provider_app_data(databag) + assert loaded.receivers[0].url == 'http://tracing.example:4318/' + assert loaded.receivers[0].protocol.name == 'otlp_http' + assert loaded.receivers[0].protocol.type is TransportProtocolType.http + + +def test_tracing_provider_app_data_missing_required_field(load_provider_app_data): + with pytest.raises(TypeError): + load_provider_app_data({}) + + +def test_tracing_load_invalid_json(load_provider_app_data): + with pytest.raises(json.JSONDecodeError): + load_provider_app_data({'receivers': 'not-json'}) + + +def test_tracing_load_ignores_extra_keys(load_provider_app_data): + databag = { + 'receivers': json.dumps([]), + 'ingress-address': json.dumps('10.0.0.1'), + 'private-address': json.dumps('10.0.0.1'), + } + loaded = load_provider_app_data(databag) + assert loaded == TracingProviderAppData(receivers=[]) diff --git a/tracing/test/test_storage.py b/tracing/test/test_storage.py index 2007b5605..5f505ce0c 100644 --- a/tracing/test/test_storage.py +++ b/tracing/test/test_storage.py @@ -21,7 +21,6 @@ from ops_tracing import _backend from ops_tracing._buffer import Destination -_pydantic = pytest.importorskip('pydantic') _export = pytest.importorskip('ops._tracing.export') diff --git a/tracing/test/test_tracing_v2_conformance.py b/tracing/test/test_tracing_v2_conformance.py new file mode 100644 index 000000000..8ffa80854 --- /dev/null +++ b/tracing/test/test_tracing_v2_conformance.py @@ -0,0 +1,188 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Conformance tests for the ``tracing`` v2 interface. + +These tests pin our requirer-side behaviour (the ``Tracing`` class and its +helpers in ``ops_tracing/_api.py``) against the behaviour the upstream charm +relation interface documents at: + + https://canonical.com/juju/docs/charmlibs/reference/interfaces/tracing/v2/ + +Each test names the verbatim "Is expected to..." clause it covers. If the +upstream doc changes the contract, the relevant test should be the place that +forces a deliberate decision about whether to follow. +""" + +from __future__ import annotations + +import json +from unittest.mock import Mock + +import ops +import ops.testing +import pytest + +# --------------------------------------------------------------------------- +# Per the upstream doc: +# +# "Tracing is done in a push-based fashion." +# +# We are the *requirer* (we push traces to the provider). The expectations +# below are the requirer-side clauses on the v2 doc page, exactly as written. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_destination(monkeypatch: pytest.MonkeyPatch) -> Mock: + rv = Mock() + monkeypatch.setattr(ops.tracing, 'set_destination', rv) + return rv + + +# "Is expected to publish a list of one or more protocols it wishes to use to +# send traces." +def test_requirer_publishes_requested_protocols( + sample_charm: type[ops.CharmBase], mock_destination: Mock +): + """The leader unit writes the ``receivers`` list to its app databag.""" + empty_relation = ops.testing.Relation('charm-tracing') + ctx = ops.testing.Context(sample_charm) + state_in = ops.testing.State(leader=True, relations={empty_relation}) + state_out = ctx.run(ctx.on.relation_changed(empty_relation), state_in) + + rel_out = state_out.get_relation(empty_relation.id) + raw = rel_out.local_app_data.get('receivers') + assert raw is not None, 'requirer did not publish a `receivers` key' + receivers = json.loads(raw) + # "a list of one or more protocols" + assert isinstance(receivers, list) + assert len(receivers) >= 1 + # Our concrete request is `otlp_http`; if this ever changes we want a + # conscious update here, not silent drift. + assert receivers == ['otlp_http'] + + +# "Is expected to publish a list of one or more protocols it wishes to use to +# send traces." (non-leader half: only the leader may write app data, so a +# follower must NOT attempt to write — it would crash the hook.) +def test_requirer_only_leader_publishes(sample_charm: type[ops.CharmBase], mock_destination: Mock): + empty_relation = ops.testing.Relation('charm-tracing') + ctx = ops.testing.Context(sample_charm) + state_in = ops.testing.State(leader=False, relations={empty_relation}) + state_out = ctx.run(ctx.on.relation_changed(empty_relation), state_in) + + rel_out = state_out.get_relation(empty_relation.id) + assert 'receivers' not in rel_out.local_app_data + + +# "Is expected to await receiving from the provider a list of endpoints." +def test_requirer_awaits_provider_endpoints( + sample_charm: type[ops.CharmBase], mock_destination: Mock +): + """Until the provider publishes a usable receiver, the destination is unset.""" + empty_relation = ops.testing.Relation('charm-tracing') + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={empty_relation}) + ctx.run(ctx.on.relation_changed(empty_relation), state) + mock_destination.assert_called_with(url=None, ca=None) + + +# "Is expected to push traces to one or more of the provided endpoints using +# the corresponding encoding/protocol." +def test_requirer_uses_provided_endpoint( + sample_charm: type[ops.CharmBase], + mock_destination: Mock, + http_relation: ops.testing.Relation, +): + """When the provider advertises our requested protocol, we point at it.""" + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={http_relation}) + ctx.run(ctx.on.relation_changed(http_relation), state) + # `otlp_http`'s OTLP/HTTP path is /v1/traces (per the OTLP spec); our + # _get_destination appends it to the base URL the provider advertises. + mock_destination.assert_called_with(url='http://tracing.example:4318/v1/traces', ca=None) + + +# "Is expected to handle cases where none of the requested protocols is +# supported." +def test_requirer_handles_no_supported_protocol( + sample_charm: type[ops.CharmBase], mock_destination: Mock +): + """Provider only offers protocols we did NOT request: degrade quietly.""" + # We request otlp_http; provider only advertises otlp_grpc. + relation = ops.testing.Relation( + 'charm-tracing', + remote_app_data={ + 'receivers': json.dumps([ + { + 'protocol': {'name': 'otlp_grpc', 'type': 'grpc'}, + 'url': 'tracing.example:4317', + } + ]), + }, + ) + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={relation}) + # Must not raise; we're "expected to handle" this case. + ctx.run(ctx.on.relation_changed(relation), state) + mock_destination.assert_called_with(url=None, ca=None) + + +# --------------------------------------------------------------------------- +# Provider-side clauses on the v2 doc page (we do NOT implement these — we are +# the requirer only). We assert the converse: that our requirer behaviour is +# correctly *driven by* what the spec promises a conforming provider will +# publish. +# +# Provider clauses, verbatim: +# "Is expected to publish the url at which the server is reachable. (This +# will happen in any case and doubles down as an acknowledgement of +# receipt)" +# "Is expected to comply as good as possible with the requested protocols, +# activating the corresponding receivers." +# "Is expected to run a server accepting trace submissions on **all** the +# supported **and** requested tracing protocols." +# "Is expected to publish, for each protocol it accepts, the port at which +# the server is listening along with the name of the supported protocol." +# --------------------------------------------------------------------------- + + +def test_requirer_picks_matching_protocol_when_multiple_offered( + sample_charm: type[ops.CharmBase], mock_destination: Mock +): + """A conforming provider may publish many receivers; we pick `otlp_http`.""" + relation = ops.testing.Relation( + 'charm-tracing', + remote_app_data={ + 'receivers': json.dumps([ + { + 'protocol': {'name': 'zipkin', 'type': 'http'}, + 'url': 'http://tracing.example:9411/', + }, + { + 'protocol': {'name': 'otlp_grpc', 'type': 'grpc'}, + 'url': 'tracing.example:4317', + }, + { + 'protocol': {'name': 'otlp_http', 'type': 'http'}, + 'url': 'http://tracing.example:4318/', + }, + ]), + }, + ) + ctx = ops.testing.Context(sample_charm) + state = ops.testing.State(leader=True, relations={relation}) + ctx.run(ctx.on.relation_changed(relation), state) + mock_destination.assert_called_with(url='http://tracing.example:4318/v1/traces', ca=None) diff --git a/tracing/test/test_upstream_schemas.py b/tracing/test/test_upstream_schemas.py new file mode 100644 index 000000000..988ef4c22 --- /dev/null +++ b/tracing/test/test_upstream_schemas.py @@ -0,0 +1,240 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pin our de-pydantic'd dataclasses against the upstream charmlibs schemas. + +This is an "opt-in" test: it requires network access to fetch the upstream +schemas at canonical/charmlibs HEAD, and it requires pydantic to instantiate +them. It is not part of the default ``unit`` tox env; run it via:: + + tox -e upstream-schemas + +The intent is to detect drift: if the canonical schemas under +``interfaces/{tracing,certificate_transfer}/`` change shape, this test should +fail and force a conscious decision about whether to follow upstream. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import enum +import json +import sys +import types +import typing +import urllib.request + +import pytest + +pydantic = pytest.importorskip('pydantic') + +from ops_tracing import _tracing_models # noqa: E402 + +TRACING_SCHEMA_URL = ( + 'https://raw.githubusercontent.com/canonical/charmlibs/main/' + 'interfaces/tracing/interface/v2/schema.py' +) +CERT_TRANSFER_SCHEMA_URL = ( + 'https://raw.githubusercontent.com/canonical/charmlibs/main/' + 'interfaces/certificate_transfer/interface/v1/schema.py' +) + + +def _fetch(url: str) -> str: + assert url.startswith('https://'), url + with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 + return resp.read().decode('utf-8') + + +def _load_upstream(url: str) -> dict[str, typing.Any]: + """Exec an upstream schema.py in an isolated namespace. + + The upstream files import ``interface_tester.schema_base.DataBagSchema``, + which is a tiny pydantic ``BaseModel`` subclass. Stub it so we don't have + to install ``pytest-interface-tester``. + """ + stub = types.ModuleType('interface_tester.schema_base') + + class DataBagSchema(pydantic.BaseModel): + pass + + stub.DataBagSchema = DataBagSchema # pyright: ignore[reportAttributeAccessIssue] + parent = types.ModuleType('interface_tester') + parent.schema_base = stub # pyright: ignore[reportAttributeAccessIssue] + sys.modules.setdefault('interface_tester', parent) + sys.modules['interface_tester.schema_base'] = stub + + ns: dict[str, typing.Any] = {'__name__': f'upstream_{url.rsplit("/", 3)[-3]}'} + exec(compile(_fetch(url), url, 'exec'), ns) # noqa: S102 + # ``Json[T]`` annotations are stored as ForwardRefs at class-construction + # time; resolve them now so ``model_fields`` and ``__init__`` work. + for value in list(ns.values()): + if isinstance(value, type) and issubclass(value, pydantic.BaseModel): + with contextlib.suppress(Exception): + value.model_rebuild(_types_namespace=ns) + return ns + + +# Represent the upstream pydantic model and our dataclass as normalised +# (field-name -> type-token) maps. Token equality is what we compare. + +_PRIMITIVE_TOKENS = { + str: 'str', + int: 'int', + float: 'float', + bool: 'bool', +} + + +def _token(tp: object) -> object: + """Normalise a typing annotation into a comparable token. + + - Strip pydantic ``Json[T]`` (it's a wire-format wrapper; our dataclasses + json-decode at the databag layer, so the inner type is what counts). + - Treat ``Literal[*strs]`` as ``str`` (we narrow ``ReceiverProtocol`` to a + Literal of the supported set; upstream leaves it open as ``str``). + - Recurse into containers and into nested BaseModel/dataclass classes. + """ + if tp in _PRIMITIVE_TOKENS: + return _PRIMITIVE_TOKENS[tp] + + origin = typing.get_origin(tp) + args = typing.get_args(tp) + + if origin is typing.Literal: + if all(isinstance(a, str) for a in args): + return 'str' + return ('literal', args) + + if origin in (list, set, frozenset, tuple): + container = {list: 'list', set: 'set', frozenset: 'set', tuple: 'tuple'}[origin] + return (container, _token(args[0])) + + # pydantic's ``Json[T]`` is ``Annotated[T, ...]``; unwrap. + if origin is not None and args and 'Json' in str(tp): + return _token(args[0]) + + if isinstance(tp, type): + if issubclass(tp, pydantic.BaseModel): + return _model_signature(tp) + if dataclasses.is_dataclass(tp): + return _dataclass_signature(tp) + if issubclass(tp, enum.Enum): + return ('enum', tuple(sorted((m.name, m.value) for m in tp))) + + return ('unknown', repr(tp)) + + +def _model_signature(model: type) -> dict[str, typing.Any]: + return {name: _token(f.annotation) for name, f in model.model_fields.items()} + + +def _dataclass_signature(cls: type) -> dict[str, typing.Any]: + hints = typing.get_type_hints(cls) + return {f.name: _token(hints[f.name]) for f in dataclasses.fields(cls)} + + +# ---- tracing v2 ---------------------------------------------------------- + + +@pytest.fixture(scope='module') +def tracing_upstream() -> dict[str, typing.Any]: + return _load_upstream(TRACING_SCHEMA_URL) + + +def test_tracing_provider_shape(tracing_upstream: dict[str, typing.Any]): + upstream = _model_signature(tracing_upstream['TracingProviderData']) + ours = _dataclass_signature(_tracing_models.TracingProviderAppData) + assert upstream == ours, ( + f'TracingProviderData drift\n upstream: {upstream}\n ours: {ours}' + ) + + +def test_tracing_requirer_shape(tracing_upstream: dict[str, typing.Any]): + upstream = _model_signature(tracing_upstream['TracingRequirerData']) + ours = _dataclass_signature(_tracing_models.TracingRequirerAppData) + assert upstream == ours, ( + f'TracingRequirerData drift\n upstream: {upstream}\n ours: {ours}' + ) + + +def test_tracing_provider_roundtrip( + tracing_upstream: dict[str, typing.Any], + load_provider_app_data, +): + """A valid upstream payload must deserialise identically through our loader.""" + upstream_cls = tracing_upstream['TracingProviderData'] + payload = { + 'receivers': json.dumps([ + {'protocol': {'name': 'otlp_http', 'type': 'http'}, 'url': 'http://example:4318'}, + {'protocol': {'name': 'otlp_grpc', 'type': 'grpc'}, 'url': 'example:4317'}, + ]) + } + upstream_obj = upstream_cls(**payload) + ours = load_provider_app_data(payload) + + upstream_receivers = [ + {'name': r.protocol.name, 'type': r.protocol.type, 'url': r.url} + for r in upstream_obj.receivers + ] + ours_receivers = [ + {'name': r.protocol.name, 'type': r.protocol.type.value, 'url': r.url} + for r in ours.receivers + ] + assert upstream_receivers == ours_receivers + + +def test_tracing_requirer_roundtrip(tracing_upstream: dict[str, typing.Any]): + upstream_cls = tracing_upstream['TracingRequirerData'] + payload = {'receivers': json.dumps(['otlp_http', 'otlp_grpc'])} + upstream_obj = upstream_cls(**payload) + ours = _tracing_models.TracingRequirerAppData(receivers=['otlp_http', 'otlp_grpc']) + assert list(upstream_obj.receivers) == list(ours.receivers) + + +# ---- certificate_transfer v1 --------------------------------------------- + + +@pytest.fixture(scope='module') +def cert_transfer_upstream() -> dict[str, typing.Any]: + return _load_upstream(CERT_TRANSFER_SCHEMA_URL) + + +def test_cert_transfer_provider_shape(cert_transfer_upstream: dict[str, typing.Any]): + upstream = _model_signature(cert_transfer_upstream['CertificateTransferProviderAppData']) + ours = _dataclass_signature(_tracing_models.CertificateTransferProviderAppData) + # ``version`` is upstream-optional metadata; we deliberately ignore it. If + # a NEW required field appears, fail loudly so we can decide whether to + # adopt it. + upstream_cls = cert_transfer_upstream['CertificateTransferProviderAppData'] + required = {name for name, f in upstream_cls.model_fields.items() if f.is_required()} + assert required <= set(ours), f'upstream added required field(s): {required - set(ours)}' + upstream_required = {k: v for k, v in upstream.items() if k in ours} + assert upstream_required == ours, ( + f'CertificateTransferProviderAppData drift\n' + f' upstream: {upstream_required}\n ours: {ours}' + ) + + +def test_cert_transfer_provider_roundtrip(cert_transfer_upstream: dict[str, typing.Any]): + """A valid upstream payload must deserialise identically through our loader.""" + upstream_cls = cert_transfer_upstream['CertificateTransferProviderAppData'] + obj = upstream_cls(certificates={'pem-a', 'pem-b'}) + dumped = obj.model_dump(mode='json') + raw = json.dumps(dumped['certificates']) + ours = _tracing_models.CertificateTransferProviderAppData( + certificates=set(json.loads(raw)), + ) + assert ours.certificates == set(obj.certificates) == {'pem-a', 'pem-b'} diff --git a/tracing/tox.ini b/tracing/tox.ini index f52acd17b..53b0e1277 100644 --- a/tracing/tox.ini +++ b/tracing/tox.ini @@ -15,7 +15,19 @@ deps = pytest -e .. -e ../testing -commands = pytest {posargs} +commands = pytest --ignore=test/test_upstream_schemas.py {posargs} + +[testenv:upstream-schemas] +description = + Drift check: compare our de-pydantic'd dataclasses against the upstream + canonical/charmlibs schemas. Opt-in (requires network + pydantic); + not part of the default ``unit`` run. +deps = + pytest + pydantic + -e .. + -e ../testing +commands = pytest test/test_upstream_schemas.py {posargs} [testenv:lint] description = Check code against coding style standards diff --git a/uv.lock b/uv.lock index f4a63355f..3ed42d7c6 100644 --- a/uv.lock +++ b/uv.lock @@ -1012,7 +1012,6 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, { name = "ops" }, - { name = "pydantic" }, ] [package.dev-dependencies] @@ -1029,7 +1028,6 @@ requires-dist = [ { name = "opentelemetry-api", specifier = "~=1.0" }, { name = "opentelemetry-sdk", specifier = "~=1.30" }, { name = "ops", editable = "." }, - { name = "pydantic" }, ] [package.metadata.requires-dev]