Skip to content
Open
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
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
/nginx_k8s/ @canonical/tracing-and-profiling
/passwd/ @canonical/charmlibs-maintainers
/pathops/ @canonical/charmlibs-maintainers
/rollingops/ @canonical/data
/snap/ @canonical/charmlibs-maintainers
/sysctl/ @canonical/charmlibs-maintainers
/systemd/ @canonical/charmlibs-maintainers
Expand Down
Empty file added rollingops/CHANGELOG.md
Empty file.
29 changes: 29 additions & 0 deletions rollingops/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# charmlibs.rollingops

The `rollingops` library.

`rollingops` provides a rolling-operations manager for Juju charms backed by etcd.

It coordinates operations across units by using etcd as a shared lock and queue backend,
and uses TLS client credentials to authenticate requests to the etcd cluster.

To install, add `charmlibs-rollingops` to your Python dependencies. Then in your Python code, import as:

```py
from charmlibs import rollingops
```

See the [reference documentation](https://documentation.ubuntu.com/charmlibs/reference/charmlibs/rollingops) for more.

## Unit tests
```py
just python=3.12 unit rollingops
```
## Pack
```py
just python=3.12 pack-machine rollingops
```
## Integration tests
```py
just python=3.12 integration-machine rollingops
```
75 changes: 75 additions & 0 deletions rollingops/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
[project]
name = "charmlibs-rollingops"
description = "The charmlibs.rollingops package."
readme = "README.md"
requires-python = ">=3.12"
authors = [
{name="Data Platform"},
]
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 = [
# "ops",
"charmlibs-interfaces-tls-certificates>=1.8.1",
]

[dependency-groups]
lint = [ # installed for `just lint rollingops` (unit, functional, and integration are also installed)
# "typing_extensions",
]
unit = [ # installed for `just unit rollingops`
"ops[testing]",
]
functional = [ # installed for `just functional rollingops`
]
integration = [ # installed for `just integration rollingops`
"jubilant",
"tenacity",
]

[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/rollingops/_version.py"

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

[tool.ruff.lint.extend-per-file-ignores]
# add additional per-file-ignores here to avoid overriding repo-level config
"tests/**/*" = [
# "E501", # line too long
]
"src/charmlibs/rollingops/_dp_interfaces_v1.py" = ["ALL"]

[tool.pyright]
extends = "../pyproject.toml"
include = ["src", "tests"]
exclude = ["**/_dp_interfaces_v1.py", "tests/integration/.tmp/**"]
pythonVersion = "3.12" # check no python > 3.12 features are used

[tool.charmlibs.functional]
ubuntu = [] # ubuntu versions to run functional tests with, e.g. "24.04" (defaults to just "latest")
pebble = [] # pebble versions to run functional tests with, e.g. "v1.0.0", "master" (defaults to no pebble versions)
sudo = false # whether to run functional tests with sudo (defaults to false)

[tool.charmlibs.integration]
# 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
36 changes: 36 additions & 0 deletions rollingops/src/charmlibs/rollingops/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 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.

"""The charmlibs.rollingops package."""

from ._certificates import CertificatesManager
from ._etcdctl import EtcdCtl
from ._manager import EtcdRollingOpsManager
from ._models import (
OperationResult,
RollingOpsEtcdNotConfiguredError,
RollingOpsKeys,
)
from ._relations import SECRET_FIELD
from ._version import __version__ as __version__

__all__ = (
'SECRET_FIELD',
'CertificatesManager',
'EtcdCtl',
'EtcdRollingOpsManager',
'OperationResult',
'RollingOpsEtcdNotConfiguredError',
'RollingOpsKeys',
)
160 changes: 160 additions & 0 deletions rollingops/src/charmlibs/rollingops/_certificates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# 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.

import logging
import os
from datetime import timedelta
from pathlib import Path

from charmlibs.interfaces.tls_certificates import (
Certificate,
CertificateRequestAttributes,
CertificateSigningRequest,
PrivateKey,
)

logger = logging.getLogger(__name__)


class CertificatesManager:
"""Manage generation and persistence of TLS certificates for etcd client access.

This class is responsible for creating and storing a client Certificate
Authority (CA) and a client certificate/key pair used to authenticate
with etcd via TLS. Certificates are generated only once and persisted
under a local directory so they can be reused across charm executions.

Certificates are valid for 20 years. They are not renewed or rotated.
"""

BASE_DIR = Path('/var/lib/rollingops/tls')

CA_CERT = BASE_DIR / 'client-ca.pem'
CLIENT_KEY = BASE_DIR / 'client.key'
CLIENT_CERT = BASE_DIR / 'client.pem'

VALIDITY_DAYS = 365 * 20

@classmethod
def _exists(cls) -> bool:
"""Check whether the client certificates and CA certificate already exist."""
return cls.CA_CERT.exists() and cls.CLIENT_KEY.exists() and cls.CLIENT_CERT.exists()

@classmethod
def client_paths(cls) -> tuple[Path, Path]:
"""Return filesystem paths for the client certificate and key.

Returns:
A tuple containing:
- Path to the client certificate
- Path to the client private key
"""
return cls.CLIENT_CERT, cls.CLIENT_KEY

@classmethod
def persist_client_cert_key_and_ca(cls, cert_pem: str, key_pem: str, ca_pem: str) -> None:
"""Persist the provided client certificate, key, and CA to disk.

Args:
cert_pem: PEM-encoded client certificate.
key_pem: PEM-encoded client private key.
ca_pem: PEM-encoded CA certificate.
"""
cls.BASE_DIR.mkdir(parents=True, exist_ok=True)

cls.CLIENT_CERT.write_text(cert_pem)
cls.CLIENT_KEY.write_text(key_pem)
cls.CA_CERT.write_text(ca_pem)

os.chmod(cls.CLIENT_CERT, 0o644)
os.chmod(cls.CLIENT_KEY, 0o600)
os.chmod(cls.CA_CERT, 0o644)

@classmethod
def has_client_cert_key_and_ca(cls, cert_pem: str, key_pem: str, ca_pem: str) -> bool:
"""Return whether the provided certificate material matches local files."""
if not cls.CLIENT_CERT.exists() or not cls.CLIENT_KEY.exists() or not cls.CA_CERT.exists():
return False

return (
cls.CLIENT_CERT.read_text() == cert_pem
and cls.CLIENT_KEY.read_text() == key_pem
and cls.CA_CERT.read_text() == ca_pem
)

@classmethod
def generate(cls, common_name: str) -> tuple[str, str, str]:
"""Generate a client CA and client certificate if they do not exist.

This method creates:
1. A CA private key and self-signed CA certificate.
2. A client private key.
3. A certificate signing request (CSR) using the provided common name.
4. A client certificate signed by the generated CA.

The generated files are written to disk and reused in future runs.
If the certificates already exist, this method does nothing.

Args:
common_name: Common Name (CN) used in the client certificate
subject. This value should not contain slashes.

Returns:
A tuple containing:
- The client certificate PEM string
- The client private key PEM string
- The client CA certificate PEM string
"""
if cls._exists():
return cls.CLIENT_CERT.read_text(), cls.CLIENT_KEY.read_text(), cls.CA_CERT.read_text()

cls.BASE_DIR.mkdir(parents=True, exist_ok=True)

ca_key = PrivateKey.generate(key_size=4096)
ca_attributes = CertificateRequestAttributes(
common_name='rollingops-client-ca', is_ca=True

Choose a reason for hiding this comment

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

Shouldn't that be common_name ? Or something decided by the charm integrating ?
Or is it because it can't be chosen in a distributed way ?

Choose a reason for hiding this comment

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

In addition to Neha's comment on the common_name, it might be worth adding add_unique_id_to_subject_name=False to the attributes.

)
ca_crt = Certificate.generate_self_signed_ca(
attributes=ca_attributes,
private_key=ca_key,
validity=timedelta(days=cls.VALIDITY_DAYS),
)

client_key = PrivateKey.generate(key_size=4096)

csr_attributes = CertificateRequestAttributes(
common_name=common_name, add_unique_id_to_subject_name=False

Choose a reason for hiding this comment

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

I would recommend to add Subject Alternative Names, as certificate verification based on common name has been deprecated.

)
csr = CertificateSigningRequest.generate(
attributes=csr_attributes,
private_key=client_key,
)

client_crt = Certificate.generate(
csr=csr,
ca=ca_crt,
ca_private_key=ca_key,
validity=timedelta(days=cls.VALIDITY_DAYS),
is_ca=False,
)

cls.CA_CERT.write_text(ca_crt.raw)
cls.CLIENT_KEY.write_text(client_key.raw)
cls.CLIENT_CERT.write_text(client_crt.raw)

os.chmod(cls.CLIENT_KEY, 0o600)
os.chmod(cls.CA_CERT, 0o644)
os.chmod(cls.CLIENT_CERT, 0o644)

return client_crt.raw, client_key.raw, ca_crt.raw
Loading
Loading