-
Notifications
You must be signed in to change notification settings - Fork 20
patch: advanced rolling ops using etcd #364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
70a4cca
b32878c
bf58a2d
4d99740
a29b85d
de57674
effafe4
3948b94
1726070
b4f700c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ``` |
| 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 |
| 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', | ||
| ) |
| 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 | ||
| ) | ||
patriciareinoso marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @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) | ||
patriciareinoso marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ca_attributes = CertificateRequestAttributes( | ||
| common_name='rollingops-client-ca', is_ca=True | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In addition to Neha's comment on the |
||
| ) | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
patriciareinoso marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return client_crt.raw, client_key.raw, ca_crt.raw | ||
Uh oh!
There was an error while loading. Please reload this page.