Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bade830
feat: add tls_certificates_testing package
james-garner-canonical Feb 2, 2026
dd4e95f
refactor: use kwargs
james-garner-canonical Feb 2, 2026
864a38d
feat: make second and subsequent args keyword only
james-garner-canonical Feb 2, 2026
3b39c82
refactor: streamline dumping the provider side
james-garner-canonical Feb 2, 2026
e529f86
test: add a simple unit test that lib versions match
james-garner-canonical Feb 2, 2026
a6842bb
chore: update uv.lock
james-garner-canonical Feb 2, 2026
14e8404
style: just format
james-garner-canonical Feb 2, 2026
1270ebb
fix: correct variables in provider side
james-garner-canonical Feb 2, 2026
4979c14
test: add unit tests for testing package logic
james-garner-canonical Feb 3, 2026
bc5a8c1
tmp: TLS Certificates type checking fixes
james-garner-canonical Feb 3, 2026
7b4097d
chore: add functional and integration groups back so lint can run
james-garner-canonical Feb 3, 2026
b9e325b
chore: satisfy type checker and linting
james-garner-canonical Feb 3, 2026
0eefee0
refactor: workaround typing issues
james-garner-canonical Feb 3, 2026
f76f3b7
refactor: correct argument name (name -> endpoint)
james-garner-canonical Feb 3, 2026
e4ef470
test: temporarily add a version test that uses state transition testing
james-garner-canonical Feb 5, 2026
8e0f987
test: add tests for a dummy requirer charm (with help from Haiku)
james-garner-canonical Feb 5, 2026
376da53
test: add Haiku's passing provider charm tests
james-garner-canonical Feb 5, 2026
da30381
test: cleanup and refactor tests
james-garner-canonical Feb 5, 2026
21bb658
feat: expose default testing private key and allow it to be specified
james-garner-canonical Feb 5, 2026
f6f37fb
style: just format
james-garner-canonical Feb 5, 2026
912c434
ci: update pyright version to latest
james-garner-canonical Feb 5, 2026
9890e14
chore: format imports for updated pyright version
james-garner-canonical Feb 5, 2026
6d14f0e
chore: remove unused pem files
james-garner-canonical Feb 5, 2026
fb44149
test: remove placeholder charm version test
james-garner-canonical Feb 5, 2026
60c51f1
tmp: run testing tests in CI and ensure versions match
james-garner-canonical Feb 5, 2026
cee3acd
chore: merge main
james-garner-canonical Feb 8, 2026
1efb3b9
chore: bump testing version to match TLS Certificates lib version
james-garner-canonical Feb 8, 2026
512daec
docs: remove inaccurate docstring
james-garner-canonical Feb 8, 2026
e35c1de
test: factor example charms into separate modules
james-garner-canonical Feb 16, 2026
417b83f
chore: for_local_<role> -> relation_for_<role>
james-garner-canonical Feb 26, 2026
0adcf87
chore: bump version to match
james-garner-canonical Feb 26, 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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,26 @@ jobs:
echo '${{ toJSON(needs) }}' | jq # logging
exit 1

testing-versions-match:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this change be hoisted into own PR?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR includes both an example of a library testing package, and the CI changes needed to support testing and releasing it (also proposed in the spec). They can be separated when either is ready to merge, but for now I think they should stay in one PR -- primarily so the tests for the testing package actually run, but also to show the CI changes required by the spec.

needs: [init]
if: ${{ needs.init.outputs.packages != '[]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@v7
- name: Ensure testing versions matches package versions
run: |
set -xueo pipefail
if .scripts/testing-versions-match.py; then
: 'Main package versions match testing package versions :)'
else
: 'At least one testing package version does not match its main package version :('
exit 1
fi

zizmor:
runs-on: ubuntu-latest
permissions:
Expand Down
2 changes: 2 additions & 0 deletions .scripts/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ def _ls(
dirs = _changed_only(root, dirs, ref=old_ref)
if only_if_version_changed:
dirs = _get_changed_versions_only(category, root, dirs, ref=old_ref)
if category == 'packages':
dirs.extend([t for p in dirs if _is_package(t := p / 'testing')])
Comment on lines +157 to +158
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change, if needed, should be in a separate PR

# Calculate only the information needed.
infos: list[Info] = []
for path in dirs:
Expand Down
61 changes: 61 additions & 0 deletions .scripts/testing-versions-match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env -S uv run --script --no-project
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move out of the example PR used in the spec


# /// script
# requires-python = ">=3.12"
# dependencies = [
# ]
# ///

# 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.

"""Exit with success if all testing packages match their main package's version.

Otherwise exit with failure and output all non-matching packages to stdout.
"""

import json
import pathlib
import subprocess
import sys


def _main() -> None:
# Get package names and versions.
ls = pathlib.Path(__file__).parent / 'ls.py'
cmd = [ls, 'packages', '--output=name', '--output=version']
infos = json.loads(subprocess.check_output(cmd))
# Split into main packages and testing packages.
main_packages: dict[str, str] = {}
testing_packages: dict[str, str] = {}
for i in infos:
name = i['name']
version = i['version']
if name.endswith('-testing'):
testing_packages[name] = version
else:
main_packages[name] = version
# Output any mismatches and exit accordingly.
errors = 0
for name, version in sorted(testing_packages.items()):
main_package_name = name.removesuffix('-testing')
main_package_version = main_packages[main_package_name]
if main_package_version != version:
print(f'{main_package_name} ({main_package_version}) != {name} ({version})')
errors += 1
sys.exit(errors)


if __name__ == '__main__':
_main()
7 changes: 7 additions & 0 deletions interfaces/tls-certificates/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ dependencies = [
"pydantic",
]

[project.optional-dependencies]
testing = [
"charmlibs-interfaces-tls-certificates-testing==1.6.0",
]
[dependency-groups]
lint = [ # installed for `just lint interfaces/tls_certificates` (unit, functional, and integration are also installed)
"typing_extensions",
Expand Down Expand Up @@ -96,3 +100,6 @@ sudo = false # whether to run functional tests with sudo (defaults to false)
# tags to run integration tests with (defaults to running once with no tag, i.e. tags = [''])
# Available in CI in tests/integration/pack.sh and integration tests as CHARMLIBS_TAG
tags = [] # Not used by the pack.sh and integration tests generated by the template

[tool.uv.sources]
charmlibs-interfaces-tls-certificates-testing = { path = "testing", editable = true }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github says that there are "white-space only changes" and doesn't show them.
Consider adding a sentence or two.
After all, this will be visible on PYPI.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is just an empty file to satisfy hatch at build time. We'd provide a real readme if we published this package.

Empty file.
65 changes: 65 additions & 0 deletions interfaces/tls-certificates/testing/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
[project]
name = "charmlibs-interfaces-tls-certificates-testing"
description = "The charmlibs.interfaces.tls_certificates_testing package."
readme = "README.md"
requires-python = ">=3.10"
authors = [
{name="The TLS team at Canonical"},
]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Intended Audience :: Developers",
"Operating System :: POSIX :: Linux",
"Development Status :: 5 - Production/Stable",
]
dynamic = ["version"]
dependencies = [
"charmlibs-interfaces-tls-certificates==1.6.0",
"ops[testing]",
]

[dependency-groups]
lint = [ # installed for `just lint interfaces/tls_certificates/testing` (alongside unit, functional, and integration)
]
unit = [ # installed for `just unit interfaces/tls_certificates/testing`
]
functional = [ # installed for `just functional interfaces/tls_certificates/testing`
]
integration = [ # installed for `just integration interfaces/tls_certificates/testing`
]


[project.urls]
"Repository" = "https://github.com/canonical/charmlibs"
"Issues" = "https://github.com/canonical/charmlibs/issues"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/charmlibs"]

[tool.hatch.version]
path = "src/charmlibs/interfaces/tls_certificates_testing/_version.py"

[tool.ruff]
extend = "../pyproject.toml"
src = ["src", "tests/unit"] # correctly sort local imports in tests

[tool.ruff.format]
quote-style = "double"

[tool.ruff.lint.extend-per-file-ignores]
"tests/*" = [
"E501", # line too long
]

[tool.pyright]
extends = "../pyproject.toml"
include = ["src", "tests"]
pythonVersion = "3.10" # check no python > 3.10 features are used

[tool.uv.sources]
charmlibs-interfaces-tls-certificates = { path = "../", editable = true }
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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.

"""Testing package for ``charmlibs.interfaces.tls_certificates``."""

from ._testing import (
DEFAULT_PRIVATE_KEY,
relation_for_provider,
relation_for_requirer,
)
from ._version import __version__ as __version__

__all__ = [
"DEFAULT_PRIVATE_KEY",
"relation_for_provider",
"relation_for_requirer",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

KEY = """
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA2Fi2idTUy12iSq8l9ErCp3cnFcn81L+QO/wGAS5ie2oiEyXR
7bdncjW7B7MbjuYrwRYP/njB1afXR5z3VsUiNUplyN9UlrODm0ZbglhXZnmOzYj+
5cibgVzMjxnbPtUM0I9VXe4Zuis6wNLLf42x4hIu/MSl8YzOPZScedAaocupHxWB
NQal2p/VbbXYIdzSkQ9OIMjR6BWT2rDQ8nIEkj8YyfbypWvf145Gz1H+hmCWsiuD
gyXWDcu5w7nDpl/GQ8Me1sRAvAGYLmmxAM634b3R3eVLwARXJPXvHBh8ciH32Vvp
+ouxunYAht3VqSlqUeeV5DxaauCVyivw9BpJ6QIDAQABAoIBACvSyoUAbOtV5/A3
atmBhE0OXIc0H2gL9sBD1IaMOgw0Szs0M8Iynrx6iExuujQXyUCnQq2iEeeAxGGf
+NUlgQCo9ZW7MWQGcG/1UEeGtAuQr33QVIvAt0k8vj31d50SZHJhumHYv3Lf21qg
SHLpxaDc3JI8lj2n70X3kru1DRP9MOtdOdl3VbLVHgMIM5xdRnFhxsNOYPVUufmV
Y0q5giuSYB9Yjrrm6Eosa+U+bNZo3QQNNNPv0QF+ErEzVsJYCkoehupypIlZE3n0
10B56Gm6J9nOr4n4EvcVieaVBElNIrblHV1T6rUGTxwARml9xZTcvhgAPE/cBTOD
+nZIdxUCgYEA9zHZ6qyiyVBlKKZ8FxSwihNPh3vxdz7195JkTwBdM6GCQfZ2HsZO
ba0MXWVGB0AxeOT+Qji0pWKclyPAFlZFC8qfl27kErSpAPCpvGC5G6mk/F9c0ius
STuACOQwznzDCT40tki52y46HIS4haFJQxmr84nUum6umV0BQe8cTs0CgYEA4A2P
QdfmORcfJ6I9VVbg70QSPb/+xTcWuWtdWYnkyCBEW8yyhCb5ZoRWgwSO+GGgSnXF
xkoxiOMWQVz3tLvVY+PHLpWIxnz8aDAHKGHfAnwDkmQ27QKzByUhSAeDbK0NcALD
uuLgqXe9U7+W2BRAnELnvDLbSMzhX3as07i4b40CgYEAg/Ye6UKj3GiIuDy4PfIT
lIJekGtAKnJ4CGQkHHCLUMbFrMUPpbojoDUjRrCLw88nGezVNzDibu/HvH+fSc1g
Kr1OmR7froS3PAM9+YyBBR15MCkQejpKTQXwgc6fp3u++q40oaMNZM62wwavItdJ
LwMDYo2P/L6dgs29oB8vs3kCgYAvCcm8uhYEgF0zFfWod//rW7A0tJ1JTEKCFQ9Z
IAEfHt8bIsOLyR7tLfV3tjpJ3T0oxMcL1UHHCl3+xQTgNdscCJMlrZE0ksLvIL4v
9TQ7skuRrWZ2pe1uH5Z4J9OoukAq9vmev8kI6zGdZojFvqK967H5KfgttY3PW/v2
yz41dQKBgQDDZcT0B7JJz1p0pK7wUUGXsNNZVp9SQ3L2H1OPf6Lj5w3fJtuqSha/
MczxNo776+Hp4zYqTc562OQRpPcEK+EpZ8Ybpt2mYkXuLh62zSmXnD0EsFeL/x71
RTLqLOmYRWP/qN5TeU4ue/k29hLHd3A7N/wAHGxdXDtCcD+1F3SQJg==
-----END RSA PRIVATE KEY-----
""".strip()

CERT = """
-----BEGIN CERTIFICATE-----
MIIDxTCCAq2gAwIBAgIUTUjLDtJdbu63T/TI6Hw+Vx0aSFowDQYJKoZIhvcNAQEL
BQAwfzELMAkGA1UEBhMCTloxETAPBgNVBAgMCEF1Y2tsYW5kMREwDwYDVQQHDAhB
dWNrbGFuZDE2MDQGA1UECgwtY2hhcm1saWJzLmludGVyZmFjZXMudGxzX2NlcnRp
ZmljYXRlc190ZXN0aW5nMRIwEAYDVQQDDAlsb2NhbGhvc3QwIBcNMjYwMjAyMDM1
MDE3WhgPMjEyNjAxMDkwMzUwMTdaMH8xCzAJBgNVBAYTAk5aMREwDwYDVQQIDAhB
dWNrbGFuZDERMA8GA1UEBwwIQXVja2xhbmQxNjA0BgNVBAoMLWNoYXJtbGlicy5p
bnRlcmZhY2VzLnRsc19jZXJ0aWZpY2F0ZXNfdGVzdGluZzESMBAGA1UEAwwJbG9j
YWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Fi2idTUy12i
Sq8l9ErCp3cnFcn81L+QO/wGAS5ie2oiEyXR7bdncjW7B7MbjuYrwRYP/njB1afX
R5z3VsUiNUplyN9UlrODm0ZbglhXZnmOzYj+5cibgVzMjxnbPtUM0I9VXe4Zuis6
wNLLf42x4hIu/MSl8YzOPZScedAaocupHxWBNQal2p/VbbXYIdzSkQ9OIMjR6BWT
2rDQ8nIEkj8YyfbypWvf145Gz1H+hmCWsiuDgyXWDcu5w7nDpl/GQ8Me1sRAvAGY
LmmxAM634b3R3eVLwARXJPXvHBh8ciH32Vvp+ouxunYAht3VqSlqUeeV5DxaauCV
yivw9BpJ6QIDAQABozcwNTAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYE
FD4pF9vzqzjcLkshgRU20mIPr5pAMA0GCSqGSIb3DQEBCwUAA4IBAQAVLsHrt0MD
CkvAXJEXzAEa81aXQ8Kj74TjEU2EkD8vGcPjZgO9MIsNxErULe//+Ei47mauIEU8
EdXQQY1kVvDlCk5/R81aFF8uedNQdHcc/1e/Tfb26TYcLtgb0fffIMhv4BR7YHp3
3FjBF1PlEiS1/zuC+Sa+L7EB2LbEqo40/qpz93qC18Qo8351/aS3W04stjCYgB+6
C/+TN4cClQ/7wJ3nN19VjDAyJT+xMS4rX5OtR2XVDdiGK07TSKkFAEjA2s8gLNOE
t56+0smk/vJec/9RWs2kt8hNwrN7fBRrXP5tftlF/xm6f1zibI8QhnjOsBdezWzV
59gr4SC0C9l8
-----END CERTIFICATE-----
""".strip()
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

from __future__ import annotations

import datetime
import typing

from ops import testing

from charmlibs.interfaces import tls_certificates

from . import _raw

if typing.TYPE_CHECKING:
from collections.abc import Iterable

DEFAULT_PRIVATE_KEY = tls_certificates.PrivateKey(raw=_raw.KEY)
_INTERFACE_NAME = "tls-certificates"
_REQUEST = tls_certificates.CertificateRequestAttributes(common_name="example.com")
_CA_CERT = tls_certificates.Certificate(raw=_raw.CERT)


class _RelationKwargs(typing.TypedDict, total=False):
local_app_data: dict[str, str]
local_unit_data: dict[str, str]
remote_app_data: dict[str, str]
remote_units_data: dict[int, dict[str, str]]


def relation_for_requirer(
# testing.Relation args
endpoint: str,
*,
# charmlibs.interfaces.tls_certificates args
mode: tls_certificates.Mode = tls_certificates.Mode.UNIT,
certificate_requests: Iterable[tls_certificates.CertificateRequestAttributes] = (_REQUEST,),
# interface 'conversation' args
provider: bool = True,
) -> testing.Relation:
kwargs: _RelationKwargs = {}
csrs = _make_csrs(certificate_requests, key=DEFAULT_PRIVATE_KEY)
# local requirer
if mode is tls_certificates.Mode.APP:
kwargs["local_app_data"] = _dump_requirer(csrs)
else:
kwargs["local_unit_data"] = _dump_requirer(csrs)
# remote provider
if provider:
kwargs["remote_app_data"] = _dump_provider(csrs, key=DEFAULT_PRIVATE_KEY)
return _relation(endpoint, kwargs=kwargs)


def relation_for_provider(
# testing.Relation args
endpoint: str,
*,
# charmlibs.interfaces.tls_certificates args
mode: tls_certificates.Mode = tls_certificates.Mode.UNIT,
certificate_requests: Iterable[tls_certificates.CertificateRequestAttributes] = (_REQUEST,),
private_key: tls_certificates.PrivateKey = DEFAULT_PRIVATE_KEY,
# interface 'conversation' args
provider: bool = True,
) -> testing.Relation:
kwargs: _RelationKwargs = {}
csrs = _make_csrs(certificate_requests, key=private_key)
# remote requirer
if mode is tls_certificates.Mode.APP:
kwargs["remote_app_data"] = _dump_requirer(csrs)
else:
kwargs["remote_units_data"] = {0: _dump_requirer(csrs)}
# local provider
if provider:
kwargs["local_app_data"] = _dump_provider(csrs, key=private_key)
return _relation(endpoint, kwargs=kwargs)


def _make_csrs(
certificate_requests: Iterable[tls_certificates.CertificateRequestAttributes],
key: tls_certificates.PrivateKey,
) -> list[tls_certificates.CertificateSigningRequest]:
return [
tls_certificates.CertificateSigningRequest.generate(attributes=r, private_key=key)
for r in certificate_requests
]


def _dump_requirer(csrs: Iterable[tls_certificates.CertificateSigningRequest]) -> dict[str, str]:
requirer = tls_certificates._tls_certificates._RequirerData(
certificate_signing_requests=[
tls_certificates._tls_certificates._CertificateSigningRequest(
certificate_signing_request=str(csr).strip(),
ca=False,
)
for csr in csrs
]
)
ret: dict[str, str] = {}
requirer.dump(ret)
return ret


def _dump_provider(
csrs: Iterable[tls_certificates.CertificateSigningRequest], key: tls_certificates.PrivateKey
) -> dict[str, str]:
provider = tls_certificates._tls_certificates._ProviderApplicationData(
certificates=[
tls_certificates._tls_certificates._Certificate(
certificate=str(_sign(csr, key=key)),
certificate_signing_request=str(csr),
ca=str(_CA_CERT),
chain=[],
)
for csr in csrs
]
)
ret: dict[str, str] = {}
provider.dump(ret)
return ret


def _sign(
csr: tls_certificates.CertificateSigningRequest, key: tls_certificates.PrivateKey
) -> tls_certificates.Certificate:
return csr.sign(ca=_CA_CERT, ca_private_key=key, validity=datetime.timedelta(days=42))


def _relation(endpoint: str, kwargs: _RelationKwargs) -> testing.Relation:
return testing.Relation(endpoint, interface=_INTERFACE_NAME, **kwargs)
Loading
Loading