Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions tracing/test/test_certificate_transfer_v1_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# 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_relation_name`` branch of
the ``Tracing`` class 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) == {}
188 changes: 188 additions & 0 deletions tracing/test/test_tracing_v2_conformance.py
Original file line number Diff line number Diff line change
@@ -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)