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..b0c3bc024 --- /dev/null +++ b/tracing/test/test_certificate_transfer_v1_conformance.py @@ -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) == {} 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)