Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
993c942
refactor(tracing): de-pydantic the vendored charm libs
claude Jun 10, 2026
22d8ae0
Merge branch 'main' into depydantic-charm-libs
tonyandrewmeyer Jun 13, 2026
6b073b3
refactor(tracing): promote depydantic'd files from vendor/ to private…
tonyandrewmeyer Jun 13, 2026
07ac078
chore(tracing): satisfy ruff --preview on de-pydantic'd modules
tonyandrewmeyer Jun 20, 2026
e8674c5
chore(tracing): satisfy ruff format --preview on de-pydantic'd modules
tonyandrewmeyer Jun 22, 2026
2767be6
ref(tracing): drop unused requirer-side surface from depydantic'd libs
tonyandrewmeyer Jun 26, 2026
6ba465d
ref(tracing): fold duplicated databag helpers into _databag module
tonyandrewmeyer Jun 26, 2026
b66bda3
ref(tracing): write requirer databag via ops.Relation.save
tonyandrewmeyer Jun 26, 2026
6b89827
ref(tracing): use 'import ops' + ops.X everywhere in depydantic'd libs
tonyandrewmeyer Jun 26, 2026
3c292a6
docs(tracing): trim de-vendoring history from depydantic'd module hea…
tonyandrewmeyer Jun 26, 2026
96f6f7d
docs(tracing): point depydantic'd module headers at canonical.com sch…
tonyandrewmeyer Jun 26, 2026
53ccfa0
ref(tracing): inline certificate_transfer databag read, drop dataclas…
tonyandrewmeyer Jun 26, 2026
a3d6aed
ref(tracing): drop CertificateTransferRequires wrapper, observe juju …
tonyandrewmeyer Jun 26, 2026
7da90db
ref(tracing): drop TracingEndpointRequirer wrapper, drive the tracing…
tonyandrewmeyer Jun 26, 2026
5d9f430
test(tracing): pin dataclasses against upstream charmlibs schemas
tonyandrewmeyer Jun 26, 2026
b5b9fbf
test(tracing): pin tracing/cert_transfer behaviour against upstream i…
tonyandrewmeyer Jun 26, 2026
d213cdc
ref(tracing): drop _databag helper, recurse nested dataclasses in Rel…
tonyandrewmeyer Jun 27, 2026
ad80d08
ref(tracing): model certificate_transfer databag, load via Relation.load
tonyandrewmeyer Jun 27, 2026
de3bfd4
Merge branch 'main' into depydantic-charm-libs
tonyandrewmeyer Jun 27, 2026
4b4e0c4
test(tracing): add gated integration tests and upstream canary
tonyandrewmeyer Jun 27, 2026
3b738e8
Merge branch 'main' into depydantic-charm-libs
tonyandrewmeyer Jun 27, 2026
ada53e3
Merge remote-tracking branch 'upstream/main' into depydantic-charm-libs
tonyandrewmeyer Jul 5, 2026
ce32888
test(tracing): port branch tests to kubectl_port_forward
tonyandrewmeyer Jul 5, 2026
3939c69
feat(tracing): handshake v1 on certificate_transfer, fall back to v0
tonyandrewmeyer Jul 5, 2026
654368d
ci: retry snap install concierge with jittered backoff
tonyandrewmeyer Jul 5, 2026
3262c14
ci: retry all snap installs with jittered backoff
tonyandrewmeyer Jul 5, 2026
27d60e1
test(tracing): settle full model before re-integrating in relation-churn
tonyandrewmeyer Jul 6, 2026
5145646
test(tracing): drop flaky span check from leader-only test
tonyandrewmeyer Jul 6, 2026
99fd6aa
Merge branch 'main' into depydantic-charm-libs
tonyandrewmeyer Jul 6, 2026
a0f90b3
ci: drop snap install retry loops
tonyandrewmeyer Jul 6, 2026
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
3 changes: 2 additions & 1 deletion .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/smoke.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/tracing-upstream-schemas.yaml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions ops/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import logging
import os
import pathlib
import typing
import warnings
from collections.abc import Mapping
from typing import (
Expand All @@ -33,6 +34,7 @@
TypedDict,
TypeVar,
cast,
get_type_hints,
)

from . import model
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions ops/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions test/integration/test_infra_canary.py
Original file line number Diff line number Diff line change
@@ -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})'
)
165 changes: 165 additions & 0 deletions test/integration/test_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
16 changes: 0 additions & 16 deletions tracing/ops_tracing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading