diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9bdec77 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +# Description + +Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. + +# Checklist: + +- [ ] My code follows the [style guidelines](/CONTRIBUTING.md) of this project +- [ ] I have performed a self-review of my own code +- [ ] I have made corresponding changes to the documentation +- [ ] I have added tests that validate the behaviour of the software +- [ ] I validated that new and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules +- [ ] I have bumped the version of the library diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..c8bc328 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,74 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '25 13 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..a120505 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + +jobs: + lint-report: + name: Lint report + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - name: Install tox + run: pip install tox + - name: Run tests using tox + run: tox -e lint + + static-analysis: + name: Static analysis + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - name: Install tox + run: pip install tox + - name: Run tests using tox + run: tox -e static + + unit-tests-with-coverage: + name: Unit tests + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - name: Install tox + run: pip install tox + - name: Run tests using tox + run: tox -e unit + + integration-test: + name: Integration tests + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup LXD + uses: canonical/setup-lxd@main + with: + channel: 5.12/stable + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: microk8s + - name: Run integration tests + run: tox -e integration + - name: Archive Tested Charm + uses: actions/upload-artifact@v3 + if: ${{ github.ref_name == 'main' }} + with: + name: tested-charm + path: .tox/**/httpreq-acme-operator_ubuntu-22.04-amd64.charm + retention-days: 5 + - name: Archive charmcraft logs + if: failure() + uses: actions/upload-artifact@v3 + with: + name: charmcraft-logs + path: /home/runner/.local/state/charmcraft/log/*.log + - name: Archive juju crashdump + if: failure() + uses: actions/upload-artifact@v3 + with: + name: juju-crashdump + path: juju-crashdump-*.tar.xz + + publish-charm: + name: Publish Charm + needs: integration-test + runs-on: ubuntu-22.04 + if: ${{ github.ref_name == 'main' }} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install charmcraft + run: sudo snap install charmcraft --classic + - name: Fetch Tested Charm + uses: actions/download-artifact@v3 + with: + name: tested-charm + - name: Move charm in current directory + run: find ./ -name httpreq-acme-operator_ubuntu-22.04-amd64.charm -exec mv -t ./ {} \; + - name: Select Charmhub channel + uses: canonical/charming-actions/channel@2.2.2 + id: channel + - name: Upload charm to Charmhub + uses: canonical/charming-actions/upload-charm@2.2.2 + with: + credentials: "${{ secrets.CHARMHUB_TOKEN }}" + github-token: "${{ secrets.GITHUB_TOKEN }}" + channel: "${{ steps.channel.outputs.name }}" diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml new file mode 100644 index 0000000..617a6cd --- /dev/null +++ b/.github/workflows/promote.yaml @@ -0,0 +1,43 @@ +name: Promote Charm + +on: + workflow_dispatch: + inputs: + promotion: + type: choice + description: Channel to promote from + options: + - edge -> beta + - beta -> candidate + - candidate -> stable + +jobs: + promote: + name: Promote Charm + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set target channel + env: + PROMOTE_FROM: ${{ github.event.inputs.promotion }} + run: | + if [ "${PROMOTE_FROM}" == "edge -> beta" ]; then + echo "promote-from=edge" >> ${GITHUB_ENV} + echo "promote-to=beta" >> ${GITHUB_ENV} + elif [ "${PROMOTE_FROM}" == "beta -> candidate" ]; then + echo "promote-from=beta" >> ${GITHUB_ENV} + echo "promote-to=candidate" >> ${GITHUB_ENV} + elif [ "${PROMOTE_FROM}" == "candidate -> stable" ]; then + echo "promote-from=candidate" >> ${GITHUB_ENV} + echo "promote-to=stable" >> ${GITHUB_ENV} + fi + - name: Promote Charm + uses: canonical/charming-actions/release-charm@2.2.0 + with: + base-channel: 22.04 + credentials: ${{ secrets.CHARMHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + destination-channel: latest/${{ env.promote-to }} + origin-channel: latest/${{ env.promote-from }} + charmcraft-channel: latest/stable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..323f2de --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +venv/ +build/ +*.charm + +.coverage +__pycache__/ +*.py[cod] + +.tox/ + +.idea/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d8414cd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing + +To make contributions to this charm, you'll need a working [development setup](https://juju.is/docs/sdk/dev-setup). + +You can use the environments created by `tox` for development: + +```shell +tox --notest -e unit +source .tox/unit/bin/activate +``` + +## Testing + +This project uses `tox` for managing test environments. There are some pre-configured environments +that can be used for linting and formatting code when you're preparing contributions to the charm: + +```shell +tox -e lint # code style +tox -e static # static analysis +tox -e unit # unit tests +tox -e integration # integration tests +tox # runs 'lint' and 'unit' environments +``` + + +## Build +Building and publishing charms is done using charmcraft (official documentation +[here](https://juju.is/docs/sdk/publishing)). You can install charmcraft using `snap`: + +```bash +sudo snap install charmcraft --channel=classic +``` + +Initialize LXD: + +```bash +lxd init --auto +``` + +Go to the charm directory and run: + +```bash +charmcraft pack +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..8de1bdb --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# httpreq-acme-operator + +## Description + +Let's Encrypt certificates in the Juju ecosystem for answering the DNS-01 +challenge through the HTTP Request plugin. + +# Pre-requisites + +This charm is a provider of the [`tls-certificates-interface`](https://github.com/canonical/tls-certificates-interface), +charms that require Let's Encrypt certificates need to implement the requirer side. + +## Usage + +Create a YAML configuration file with the following fields: + +```yaml +httpreq-acme-operator: + email: +``` + +Deploy `httpreq-acme-operator`: + +```bash +juju deploy httpreq-acme-operator --config +``` + +Relate it to a `tls-certificates-requirer` charm: + +```bash +juju relate httpreq-acme-operator:certificates +```` + +## Config + +### Required configuration properties + +- email: Let's Encrypt email address + +### Optional configuration properties + +- server: Let's Encrypt server to use (default: `https://acme-v02.api.letsencrypt.org/directory`) + +## Relations + +- `certificates`: `tls-certificates-interface` provider + +## OCI Images + +- [Lego Rock Image](https://github.com/canonical/lego-rock) diff --git a/charmcraft.yaml b/charmcraft.yaml new file mode 100644 index 0000000..51da78b --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,17 @@ +type: "charm" +bases: + - build-on: + - name: "ubuntu" + channel: "22.04" + run-on: + - name: "ubuntu" + channel: "22.04" + +parts: + charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo + - pkg-config diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..f6d18ef --- /dev/null +++ b/config.yaml @@ -0,0 +1,29 @@ +options: + email: + type: string + description: Account email address + server: + type: string + description: Certificate authority server + default: "https://acme-v02.api.letsencrypt.org/directory" + httpreq_endpoint: + type: string + description: URL of the HTTP endpoint to use + httpreq_http_timeout: + type: int + description: API request timeout + httpreq_mode: + type: string + description: "'RAW' or None" + httpreq_password: + type: string + description: Basic authentication password + httpreq_polling_interval: + type: int + description: Time between DNS propagation check + httpreq_propagation_timeout: + type: int + description: Maximum waiting time for DNS propagation + httpreq_username: + type: string + description: Basic authentication username diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..f1b5814 --- /dev/null +++ b/icon.svg @@ -0,0 +1,93 @@ + +image/svg+xmleclispe-cheeclispe-cheCreated with Sketch.ACME diff --git a/lib/charms/acme_client_operator/v0/acme_client.py b/lib/charms/acme_client_operator/v0/acme_client.py new file mode 100644 index 0000000..a272f48 --- /dev/null +++ b/lib/charms/acme_client_operator/v0/acme_client.py @@ -0,0 +1,251 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""# acme_client Library. + +This library is designed to enable developers to easily create new charms for the ACME protocol. +This library contains all the logic necessary to get certificates from an ACME server. + +## Getting Started +To get started using the library, you need to fetch the library using `charmcraft`. +```shell +charmcraft fetch-lib charms.acme_client_operator.v0.acme_client +``` +You will also need to add the following library to the charm's `requirements.txt` file: +- jsonschema +- cryptography + +Then, to use the library in an example charm, you can do the following: +```python +from charms.acme_client_operator.v0.acme_client import AcmeClient +from ops.main import main +class ExampleAcmeCharm(AcmeClient): + def __init__(self, *args): + super().__init__(*args, plugin="namecheap") + self._server = "https://acme-staging-v02.api.letsencrypt.org/directory" + self.framework.observe(self.on.config_changed, self._on_config_changed) + + def _on_config_changed(self, _): + if not self._validate_registrar_config(): + return + if not self.validate_generic_acme_config(): + return + self.unit.status = ActiveStatus() + + @property + def _plugin_config(self): + return None +``` +Charms that leverage this library also need to specify a `provides` relation in their +`metadata.yaml` file. For example: +```yaml +provides: + certificates: + interface: tls-certificates +``` +""" +import abc +import logging +import re +from abc import abstractmethod +from typing import Dict, List, Optional, Union +from urllib.parse import urlparse + +from charms.tls_certificates_interface.v1.tls_certificates import ( # type: ignore[import] + CertificateCreationRequestEvent, + TLSCertificatesProvidesV1, +) +from cryptography import x509 +from cryptography.x509.oid import NameOID +from ops.charm import CharmBase +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus +from ops.pebble import ExecError + +# The unique Charmhub library identifier, never change it +LIBID = "b3c9913b68dc42b89dfd0e77ac57236d" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 2 + +logger = logging.getLogger(__name__) + + +class AcmeClient(CharmBase): + """Base charm for charms that use the ACME protocol to get certificates. + + This charm implements the tls_certificates interface as a provider. + """ + + __metaclass__ = abc.ABCMeta + + def __init__(self, *args, plugin: str): + super().__init__(*args) + self._csr_path = "/tmp/csr.pem" + self._certs_path = "/tmp/.lego/certificates/" + self._container_name = list(self.meta.containers.values())[0].name + self._container = self.unit.get_container(self._container_name) + self.tls_certificates = TLSCertificatesProvidesV1(self, "certificates") + self.framework.observe( + self.tls_certificates.on.certificate_creation_request, + self._on_certificate_creation_request, + ) + self._plugin = plugin + + def validate_generic_acme_config(self) -> bool: + """Validates generic ACME config.""" + if not self._email: + self.unit.status = BlockedStatus("Email address was not provided") + return False + if not self._server: + self.unit.status = BlockedStatus("ACME server was not provided") + return False + if not self._email_is_valid(self._email): + self.unit.status = BlockedStatus("Invalid email address") + return False + if not self._server_is_valid(self._server): + self.unit.status = BlockedStatus("Invalid ACME server") + return False + return True + + @staticmethod + def _get_subject_from_csr(certificate_signing_request: str) -> str: + """Returns subject from a provided CSR.""" + csr = x509.load_pem_x509_csr(certificate_signing_request.encode()) + subject_value = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + if isinstance(subject_value, bytes): + return subject_value.decode() + else: + return subject_value + + def _push_csr_to_workload(self, csr: str) -> None: + """Pushes CSR to workload container.""" + self._container.push(path=self._csr_path, make_dirs=True, source=csr.encode()) + + def _execute_lego_cmd(self) -> bool: + """Executes lego command in workload container.""" + process = self._container.exec( + self._cmd, timeout=300, working_dir="/tmp", environment=self._plugin_config + ) + try: + stdout, error = process.wait_output() + logger.info(f"Return message: {stdout}, {error}") + except ExecError as e: + logger.error("Exited with code %d. Stderr:", e.exit_code) + for line in e.stderr.splitlines(): # type: ignore + logger.error(" %s", line) + return False + return True + + def _pull_certificates_from_workload(self, csr_subject: str) -> List[Union[bytes, str]]: + """Pulls certificates from workload container.""" + chain_pem = self._container.pull(path=f"{self._certs_path}{csr_subject}.crt") + return [cert for cert in chain_pem.read().split("\n\n")] # type: ignore[arg-type] + + def _on_certificate_creation_request(self, event: CertificateCreationRequestEvent) -> None: + """Handles certificate creation request event. + + - Retrieves subject from CSR + - Pushes CSR to workload container + - Executes lego command in workload + - Pulls certificates from workload + - Sends certificates to requesting charm + """ + if not self.validate_generic_acme_config(): + self.unit.status = BlockedStatus("Invalid ACME configuration") + event.defer() + return + if not self.unit.is_leader(): + return + if not self._container.can_connect(): + self.unit.status = WaitingStatus("Waiting for container to be ready") + event.defer() + return + csr_subject = self._get_subject_from_csr(event.certificate_signing_request) + if len(csr_subject) > 64: + self.unit.status = BlockedStatus( + f"Subject is too long (> 64 characters): {csr_subject}" + ) + return + logger.info("Received Certificate Creation Request for domain %s", csr_subject) + self._push_csr_to_workload(event.certificate_signing_request) + self.unit.status = MaintenanceStatus("Executing lego command") + if not self._execute_lego_cmd(): + self.unit.status = BlockedStatus( + "Workload command execution failed, use `juju debug-log` for more information." + ) + return + signed_certificates = self._pull_certificates_from_workload(csr_subject) + self.tls_certificates.set_relation_certificate( + certificate=signed_certificates[0], + certificate_signing_request=event.certificate_signing_request, + ca=signed_certificates[-1], + chain=list(reversed(signed_certificates)), + relation_id=event.relation_id, + ) + self.unit.status = ActiveStatus() + + @property + def _cmd(self) -> List[str]: + """Command to run to get the certificate. + + Returns: + list[str]: Command and args to run. + """ + if not self._email: + raise ValueError("Email address was not provided") + if not self._server: + raise ValueError("ACME server was not provided") + return [ + "lego", + "--email", + self._email, + "--accept-tos", + "--csr", + self._csr_path, + "--server", + self._server, + "--dns", + self._plugin, + "run", + ] + + @property + @abstractmethod + def _plugin_config(self) -> Dict[str, str]: + """Plugin specific additional configuration for the command. + + Implement this method in your charm to return a dictionary with the plugin specific + configuration. + + Returns: + dict[str, str]: Plugin specific configuration. + """ + + @staticmethod + def _email_is_valid(email: str) -> bool: + """Validate the format of the email address.""" + if not re.match(r"[^@]+@[^@]+\.[^@]+", email): + return False + return True + + @staticmethod + def _server_is_valid(server: str) -> bool: + """Validate the format of the ACME server address.""" + urlparts = urlparse(server) + if not all([urlparts.scheme, urlparts.netloc]): + return False + return True + + @property + def _email(self) -> Optional[str]: + """Email address to use for the ACME account.""" + return self.model.config.get("email", None) + + @property + def _server(self) -> Optional[str]: + """ACME server address.""" + return self.model.config.get("server", None) diff --git a/lib/charms/tls_certificates_interface/v1/tls_certificates.py b/lib/charms/tls_certificates_interface/v1/tls_certificates.py new file mode 100644 index 0000000..a03b318 --- /dev/null +++ b/lib/charms/tls_certificates_interface/v1/tls_certificates.py @@ -0,0 +1,1218 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the tls-certificates relation. + +This library contains the Requires and Provides classes for handling the tls-certificates +interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.tls_certificates_interface.v1.tls_certificates +``` + +Add the following libraries to the charm's `requirements.txt` file: +- jsonschema +- cryptography + +Add the following section to the charm's `charmcraft.yaml` file: +```yaml +parts: + charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo +``` + +### Provider charm +The provider charm is the charm providing certificates to another charm that requires them. In +this example, the provider charm is storing its private key using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateCreationRequestEvent, + CertificateRevocationRequestEvent, + TLSCertificatesProvidesV1, + generate_private_key, +) +from ops.charm import CharmBase, InstallEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +def generate_ca(private_key: bytes, subject: str) -> str: + return "whatever ca content" + + +def generate_certificate(ca: str, private_key: str, csr: str) -> str: + return "Whatever certificate" + + +class ExampleProviderCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.certificates = TLSCertificatesProvidesV1(self, "certificates") + self.framework.observe( + self.certificates.on.certificate_request, self._on_certificate_request + ) + self.framework.observe( + self.certificates.on.certificate_revoked, self._on_certificate_revocation_request + ) + self.framework.observe(self.on.install, self._on_install) + + def _on_install(self, event: InstallEvent) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + ca_certificate = generate_ca(private_key=private_key, subject="whatever") + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + { + "private_key_password": "banana", + "private_key": private_key, + "ca_certificate": ca_certificate, + } + ) + self.unit.status = ActiveStatus() + + def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + ca_certificate = replicas_relation.data[self.app].get("ca_certificate") + private_key = replicas_relation.data[self.app].get("private_key") + certificate = generate_certificate( + ca=ca_certificate, + private_key=private_key, + csr=event.certificate_signing_request, + ) + + self.certificates.set_relation_certificate( + certificate=certificate, + certificate_signing_request=event.certificate_signing_request, + ca=ca_certificate, + chain=[ca_certificate, certificate], + relation_id=event.relation_id, + ) + + def _on_certificate_revocation_request(self, event: CertificateRevocationRequestEvent) -> None: + # Do what you want to do with this information + pass + + +if __name__ == "__main__": + main(ExampleProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. In +this example, the requirer charm is storing its certificates using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + TLSCertificatesRequiresV1, + generate_csr, + generate_private_key, +) +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +class ExampleRequirerCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.cert_subject = "whatever" + self.certificates = TLSCertificatesRequiresV1(self, "certificates") + self.framework.observe(self.on.install, self._on_install) + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + self.framework.observe( + self.certificates.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.certificates.on.certificate_expiring, self._on_certificate_expiring + ) + + def _on_install(self, event) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + {"private_key_password": "banana", "private_key": private_key.decode()} + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + replicas_relation.data[self.app].update({"csr": csr.decode()}) + self.certificates.request_certificate_creation(certificate_signing_request=csr) + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update({"certificate": event.certificate}) + replicas_relation.data[self.app].update({"ca": event.ca}) + replicas_relation.data[self.app].update({"chain": event.chain}) + self.unit.status = ActiveStatus() + + def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + old_csr = replicas_relation.data[self.app].get("csr") + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + + +if __name__ == "__main__": + main(ExampleRequirerCharm) +``` +""" # noqa: D405, D410, D411, D214, D416 + +import copy +import json +import logging +import uuid +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from jsonschema import exceptions, validate # type: ignore[import] +from ops.charm import CharmBase, CharmEvents, RelationChangedEvent, UpdateStatusEvent +from ops.framework import EventBase, EventSource, Handle, Object + +# The unique Charmhub library identifier, never change it +LIBID = "afd8c2bccf834997afce12c2706d2ede" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 9 + +REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/requirer.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` requirer root schema", + "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 + "examples": [ + { + "certificate_signing_requests": [ + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + ] + } + ], + "properties": { + "certificate_signing_requests": { + "type": "array", + "items": { + "type": "object", + "properties": {"certificate_signing_request": {"type": "string"}}, + "required": ["certificate_signing_request"], + }, + } + }, + "required": ["certificate_signing_requests"], + "additionalProperties": True, +} + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/provider.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` provider root schema", + "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 + "example": [ + { + "certificates": [ + { + "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": [ + "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 + ], + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 + "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 + } + ] + } + ], + "properties": { + "certificates": { + "$id": "#/properties/certificates", + "type": "array", + "items": { + "$id": "#/properties/certificates/items", + "type": "object", + "required": ["certificate_signing_request", "certificate", "ca", "chain"], + "properties": { + "certificate_signing_request": { + "$id": "#/properties/certificates/items/certificate_signing_request", + "type": "string", + }, + "certificate": { + "$id": "#/properties/certificates/items/certificate", + "type": "string", + }, + "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, + "chain": { + "$id": "#/properties/certificates/items/chain", + "type": "array", + "items": { + "type": "string", + "$id": "#/properties/certificates/items/chain/items", + }, + }, + }, + "additionalProperties": True, + }, + } + }, + "required": ["certificates"], + "additionalProperties": True, +} + + +logger = logging.getLogger(__name__) + + +class CertificateAvailableEvent(EventBase): + """Charm Event triggered when a TLS certificate is available.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class CertificateExpiringEvent(EventBase): + """Charm Event triggered when a TLS certificate is almost expired.""" + + def __init__(self, handle, certificate: str, expiry: str): + """CertificateExpiringEvent. + + Args: + handle (Handle): Juju framework handle + certificate (str): TLS Certificate + expiry (str): Datetime string reprensenting the time at which the certificate + won't be valid anymore. + """ + super().__init__(handle) + self.certificate = certificate + self.expiry = expiry + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate, "expiry": self.expiry} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.expiry = snapshot["expiry"] + + +class CertificateExpiredEvent(EventBase): + """Charm Event triggered when a TLS certificate is expired.""" + + def __init__(self, handle: Handle, certificate: str): + super().__init__(handle) + self.certificate = certificate + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + + +class CertificateCreationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate is required.""" + + def __init__(self, handle: Handle, certificate_signing_request: str, relation_id: int): + super().__init__(handle) + self.certificate_signing_request = certificate_signing_request + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate_signing_request": self.certificate_signing_request, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.relation_id = snapshot["relation_id"] + + +class CertificateRevocationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate needs to be revoked.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +def _load_relation_data(raw_relation_data: dict) -> dict: + """Loads relation data from the relation data bag. + + Json loads all data. + + Args: + raw_relation_data: Relation data from the databag + + Returns: + dict: Relation data in dict format. + """ + certificate_data = dict() + for key in raw_relation_data: + try: + certificate_data[key] = json.loads(raw_relation_data[key]) + except (json.decoder.JSONDecodeError, TypeError): + certificate_data[key] = raw_relation_data[key] + return certificate_data + + +def generate_ca( + private_key: bytes, + subject: str, + private_key_password: Optional[bytes] = None, + validity: int = 365, + country: str = "US", +) -> bytes: + """Generates a CA Certificate. + + Args: + private_key (bytes): Private key + subject (str): Certificate subject + private_key_password (bytes): Private key password + validity (int): Certificate validity time (in days) + country (str): Certificate Issuing country + + Returns: + bytes: CA Certificate. + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), + x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), + ] + ) + subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( + private_key_object.public_key() # type: ignore[arg-type] + ) + subject_identifier = key_identifier = subject_identifier_object.public_bytes() + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key_object.public_key()) # type: ignore[arg-type] + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) + .add_extension( + x509.AuthorityKeyIdentifier( + key_identifier=key_identifier, + authority_cert_issuer=None, + authority_cert_serial_number=None, + ), + critical=False, + ) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] + ) + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_certificate( + csr: bytes, + ca: bytes, + ca_key: bytes, + ca_key_password: Optional[bytes] = None, + validity: int = 365, + alt_names: list = None, +) -> bytes: + """Generates a TLS certificate based on a CSR. + + Args: + csr (bytes): CSR + ca (bytes): CA Certificate + ca_key (bytes): CA private key + ca_key_password: CA private key password + validity (int): Certificate validity (in days) + alt_names: Certificate Subject alternative names + + Returns: + bytes: Certificate + """ + csr_object = x509.load_pem_x509_csr(csr) + subject = csr_object.subject + issuer = x509.load_pem_x509_certificate(ca).issuer + private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) + + certificate_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(csr_object.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + ) + if alt_names: + names = [x509.DNSName(n) for n in alt_names] + certificate_builder = certificate_builder.add_extension( + x509.SubjectAlternativeName(names), + critical=False, + ) + certificate_builder._version = x509.Version.v1 + cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_pfx_package( + certificate: bytes, + private_key: bytes, + package_password: str, + private_key_password: Optional[bytes] = None, +) -> bytes: + """Generates a PFX package to contain the TLS certificate and private key. + + Args: + certificate (bytes): TLS certificate + private_key (bytes): Private key + package_password (str): Password to open the PFX package + private_key_password (bytes): Private key password + + Returns: + bytes: + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + certificate_object = x509.load_pem_x509_certificate(certificate) + name = certificate_object.subject.rfc4514_string() + pfx_bytes = pkcs12.serialize_key_and_certificates( + name=name.encode(), + cert=certificate_object, + key=private_key_object, # type: ignore[arg-type] + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), + ) + return pfx_bytes + + +def generate_private_key( + password: Optional[bytes] = None, + key_size: int = 2048, + public_exponent: int = 65537, +) -> bytes: + """Generates a private key. + + Args: + password (bytes): Password for decrypting the private key + key_size (int): Key size in bytes + public_exponent: Public exponent. + + Returns: + bytes: Private Key + """ + private_key = rsa.generate_private_key( + public_exponent=public_exponent, + key_size=key_size, + ) + key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption(password) + if password + else serialization.NoEncryption(), + ) + return key_bytes + + +def generate_csr( + private_key: bytes, + subject: str, + add_unique_id_to_subject_name: bool = True, + organization: str = None, + email_address: str = None, + country_name: str = None, + private_key_password: Optional[bytes] = None, + sans: Optional[List[str]] = None, + additional_critical_extensions: Optional[List] = None, +) -> bytes: + """Generates a CSR using private key and subject. + + Args: + private_key (bytes): Private key + subject (str): CSR Subject. + add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's + subject name. Always leave to "True" when the CSR is used to request certificates + using the tls-certificates relation. + organization (str): Name of organization. + email_address (str): Email address. + country_name (str): Country Name. + private_key_password (bytes): Private key password + sans (list): List of subject alternative names + additional_critical_extensions (list): List if critical additional extension objects. + Object must be a x509 ExtensionType. + + Returns: + bytes: CSR + """ + signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) + subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] + if add_unique_id_to_subject_name: + unique_identifier = uuid.uuid4() + subject_name.append( + x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) + ) + if organization: + subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) + if email_address: + subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) + if country_name: + subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) + csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) + if sans: + csr = csr.add_extension( + x509.SubjectAlternativeName([x509.DNSName(san) for san in sans]), critical=False + ) + if additional_critical_extensions: + for extension in additional_critical_extensions: + csr = csr.add_extension(extension, critical=True) + signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] + return signed_certificate.public_bytes(serialization.Encoding.PEM) + + +class CertificatesProviderCharmEvents(CharmEvents): + """List of events that the TLS Certificates provider charm can leverage.""" + + certificate_creation_request = EventSource(CertificateCreationRequestEvent) + certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) + + +class CertificatesRequirerCharmEvents(CharmEvents): + """List of events that the TLS Certificates requirer charm can leverage.""" + + certificate_available = EventSource(CertificateAvailableEvent) + certificate_expiring = EventSource(CertificateExpiringEvent) + certificate_expired = EventSource(CertificateExpiredEvent) + + +class TLSCertificatesProvidesV1(Object): + """TLS certificates provider class to be instantiated by TLS certificates providers.""" + + on = CertificatesProviderCharmEvents() + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.charm = charm + self.relationship_name = relationship_name + + def _add_certificate( + self, + relation_id: int, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + ) -> None: + """Adds certificate to relation data. + + Args: + relation_id (int): Relation id + certificate (str): Certificate + certificate_signing_request (str): Certificate Signing Request + ca (str): CA Certificate + chain (list): CA Chain + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_certificate = { + "certificate": certificate, + "certificate_signing_request": certificate_signing_request, + "ca": ca, + "chain": chain, + } + provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + provider_certificates = provider_relation_data.get("certificates", []) + certificates = copy.deepcopy(provider_certificates) + if new_certificate in certificates: + logger.info("Certificate already in relation data - Doing nothing") + return + certificates.append(new_certificate) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + def _remove_certificate( + self, + relation_id: int, + certificate: str = None, + certificate_signing_request: str = None, + ) -> None: + """Removes certificate from a given relation based on user provided certificate or csr. + + Args: + relation_id (int): Relation id + certificate (str): Certificate (optional) + certificate_signing_request: Certificate signing request (optional) + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} with relation id {relation_id} does not exist" + ) + provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + provider_certificates = provider_relation_data.get("certificates", []) + certificates = copy.deepcopy(provider_certificates) + for certificate_dict in certificates: + if certificate and certificate_dict["certificate"] == certificate: + certificates.remove(certificate_dict) + if ( + certificate_signing_request + and certificate_dict["certificate_signing_request"] == certificate_signing_request + ): + certificates.remove(certificate_dict) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Uses JSON schema validator to validate relation data content. + + Args: + certificates_data (dict): Certificate data dictionary as retrieved from relation data. + + Returns: + bool: True/False depending on whether the relation data follows the json schema. + """ + try: + validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def revoke_all_certificates(self) -> None: + """Revokes all certificates of this provider. + + This method is meant to be used when the Root CA has changed. + """ + for relation in self.model.relations[self.relationship_name]: + relation.data[self.model.app]["certificates"] = json.dumps([]) + + def set_relation_certificate( + self, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + relation_id: int, + ) -> None: + """Adds certificates to relation data. + + Args: + certificate (str): Certificate + certificate_signing_request (str): Certificate signing request + ca (str): CA Certificate + chain (list): CA Chain + relation_id (int): Juju relation ID + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + self._remove_certificate( + certificate_signing_request=certificate_signing_request.strip(), + relation_id=relation_id, + ) + self._add_certificate( + relation_id=relation_id, + certificate=certificate.strip(), + certificate_signing_request=certificate_signing_request.strip(), + ca=ca.strip(), + chain=[cert.strip() for cert in chain], + ) + + def remove_certificate(self, certificate: str) -> None: + """Removes a given certificate from relation data. + + Args: + certificate (str): TLS Certificate + + Returns: + None + """ + certificates_relation = self.model.relations[self.relationship_name] + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + for certificate_relation in certificates_relation: + self._remove_certificate(certificate=certificate, relation_id=certificate_relation.id) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggerred on relation changed event. + + Looks at the relation data and either emits: + - certificate request event: If the unit relation data contains a CSR for which + a certificate does not exist in the provider relation data. + - certificate revocation event: If the provider relation data contains a CSR for which + a csr does not exist in the requirer relation data. + + Args: + event: Juju event + + Returns: + None + """ + assert event.unit is not None + requirer_relation_data = _load_relation_data(event.relation.data[event.unit]) + provider_relation_data = _load_relation_data(event.relation.data[self.charm.app]) + if not self._relation_data_is_valid(requirer_relation_data): + logger.warning( + f"Relation data did not pass JSON Schema validation: {requirer_relation_data}" + ) + return + provider_certificates = provider_relation_data.get("certificates", []) + requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) + provider_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in provider_certificates + ] + requirer_unit_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in requirer_csrs + ] + for certificate_signing_request in requirer_unit_csrs: + if certificate_signing_request not in provider_csrs: + self.on.certificate_creation_request.emit( + certificate_signing_request=certificate_signing_request, + relation_id=event.relation.id, + ) + self._revoke_certificates_for_which_no_csr_exists(relation_id=event.relation.id) + + def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None: + """Revokes certificates for which no unit has a CSR. + + Goes through all generated certificates and compare agains the list of CSRS for all units + of a given relationship. + + Args: + relation_id (int): Relation id + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + provider_relation_data = _load_relation_data(certificates_relation.data[self.charm.app]) + list_of_csrs: List[str] = [] + for unit in certificates_relation.units: + requirer_relation_data = _load_relation_data(certificates_relation.data[unit]) + requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) + list_of_csrs.extend(csr["certificate_signing_request"] for csr in requirer_csrs) + provider_certificates = provider_relation_data.get("certificates", []) + for certificate in provider_certificates: + if certificate["certificate_signing_request"] not in list_of_csrs: + self.on.certificate_revocation_request.emit( + certificate=certificate["certificate"], + certificate_signing_request=certificate["certificate_signing_request"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + self.remove_certificate(certificate=certificate["certificate"]) + + +class TLSCertificatesRequiresV1(Object): + """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" + + on = CertificatesRequirerCharmEvents() + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + expiry_notification_time: int = 168, + ): + """Generates/use private key and observes relation changed event. + + Args: + charm: Charm object + relationship_name: Juju relation name + expiry_notification_time (int): Time difference between now and expiry (in hours). + Used to trigger the CertificateExpiring event. Default: 7 days. + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.expiry_notification_time = expiry_notification_time + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe(charm.on.update_status, self._on_update_status) + + @property + def _requirer_csrs(self) -> List[Dict[str, str]]: + """Returns list of requirer CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + requirer_relation_data = _load_relation_data(relation.data[self.model.unit]) + return requirer_relation_data.get("certificate_signing_requests", []) + + @property + def _provider_certificates(self) -> List[Dict[str, str]]: + """Returns list of provider CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + if not relation.app: + raise RuntimeError(f"Remote app for relation {self.relationship_name} does not exist") + provider_relation_data = _load_relation_data(relation.data[relation.app]) + return provider_relation_data.get("certificates", []) + + def _add_requirer_csr(self, csr: str) -> None: + """Adds CSR to relation data. + + Args: + csr (str): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_csr_dict = {"certificate_signing_request": csr} + if new_csr_dict in self._requirer_csrs: + logger.info("CSR already in relation data - Doing nothing") + return + requirer_csrs = copy.deepcopy(self._requirer_csrs) + requirer_csrs.append(new_csr_dict) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def _remove_requirer_csr(self, csr: str) -> None: + """Removes CSR from relation data. + + Args: + csr (str): Certificate signing request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + requirer_csrs = copy.deepcopy(self._requirer_csrs) + csr_dict = {"certificate_signing_request": csr} + if csr_dict not in requirer_csrs: + logger.info("CSR not in relation data - Doing nothing") + return + requirer_csrs.remove(csr_dict) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def request_certificate_creation(self, certificate_signing_request: bytes) -> None: + """Request TLS certificate to provider charm. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + message = ( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + logger.error(message) + raise RuntimeError(message) + self._add_requirer_csr(certificate_signing_request.decode().strip()) + logger.info("Certificate request sent to provider") + + def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: + """Removes CSR from relation data. + + The provider of this relation is then expected to remove certificates associated to this + CSR from the relation data as well and emit a request_certificate_revocation event for the + provider charm to interpret. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + self._remove_requirer_csr(certificate_signing_request.decode().strip()) + logger.info("Certificate revocation sent to provider") + + def request_certificate_renewal( + self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes + ) -> None: + """Renews certificate. + + Removes old CSR from relation data and adds new one. + + Args: + old_certificate_signing_request: Old CSR + new_certificate_signing_request: New CSR + + Returns: + None + """ + try: + self.request_certificate_revocation( + certificate_signing_request=old_certificate_signing_request + ) + except RuntimeError: + logger.warning("Certificate revocation failed.") + self.request_certificate_creation( + certificate_signing_request=new_certificate_signing_request + ) + logger.info("Certificate renewal request completed.") + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Checks whether relation data is valid based on json schema. + + Args: + certificates_data: Certificate data in dict format. + + Returns: + bool: Whether relation data is valid. + """ + try: + validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggerred on relation changed events. + + Args: + event: Juju event + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + logger.warning(f"No relation: {self.relationship_name}") + return + if not relation.app: + logger.warning(f"No remote app in relation: {self.relationship_name}") + return + provider_relation_data = _load_relation_data(relation.data[relation.app]) + if not self._relation_data_is_valid(provider_relation_data): + logger.warning( + f"Provider relation data did not pass JSON Schema validation: " + f"{event.relation.data[relation.app]}" + ) + return + requirer_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in self._requirer_csrs + ] + for certificate in self._provider_certificates: + if certificate["certificate_signing_request"] in requirer_csrs: + self.on.certificate_available.emit( + certificate_signing_request=certificate["certificate_signing_request"], + certificate=certificate["certificate"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Triggered on update status event. + + Goes through each certificate in the "certificates" relation and checks their expiry date. + If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if + they are expired, emits a CertificateExpiredEvent. + + Args: + event (UpdateStatusEvent): Juju event + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + logger.warning(f"No relation: {self.relationship_name}") + return + if not relation.app: + logger.warning(f"No remote app in relation: {self.relationship_name}") + return + provider_relation_data = _load_relation_data(relation.data[relation.app]) + if not self._relation_data_is_valid(provider_relation_data): + logger.warning( + f"Provider relation data did not pass JSON Schema validation: " + f"{relation.data[relation.app]}" + ) + return + for certificate_dict in self._provider_certificates: + certificate = certificate_dict["certificate"] + try: + certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) + except ValueError: + logger.warning("Could not load certificate.") + continue + time_difference = certificate_object.not_valid_after - datetime.utcnow() + if time_difference.total_seconds() < 0: + logger.warning("Certificate is expired") + self.on.certificate_expired.emit(certificate=certificate) + self.request_certificate_revocation(certificate.encode()) + continue + if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): + logger.warning("Certificate almost expired") + self.on.certificate_expiring.emit( + certificate=certificate, expiry=certificate_object.not_valid_after.isoformat() + ) diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..9f4e94f --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,30 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +name: httpreq-acme-operator + +display-name: HTTP Request ACME Operator + +description: | + ACME operator implementing the provider side of the `tls-certificates` + interface to get signed certificates from the `Let's Encrypt` ACME server + using the HTTP Request plugin for DNS-01 challenge. +summary: | + ACME operator implementing the provider side of the `tls-certificates` + interface to get signed certificates from the `Let's Encrypt` ACME server + using the HTTP Request plugin for DNS-01 challenge. +website: https://github.com/canonical/httpreq-acme-operator + +provides: + certificates: + interface: tls-certificates + +containers: + lego: + resource: lego-image + +resources: + lego-image: + type: oci-image + description: Distroless OCI image for lego built with rockcraft. + upstream-source: ghcr.io/canonical/lego:4.9.1 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2edc519 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +# Testing tools configuration +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +# Formatting tools configuration +[tool.black] +line-length = 99 +target-version = ["py38"] + +[tool.isort] +line_length = 99 +profile = "black" + +# Linting tools configuration +[tool.flake8] +max-line-length = 99 +max-doc-length = 99 +max-complexity = 10 +exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"] +select = ["E", "W", "F", "C", "N", "R", "D", "H"] +# Ignore W503, E501 because using black creates errors with this +# Ignore D107 Missing docstring in __init__ +ignore = ["W503", "E501", "D107"] +# D100, D101, D102, D103: Ignore missing docstrings in tests +per-file-ignores = ["tests/*:D100,D101,D102,D103,D104"] +docstring-convention = "google" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cb02b74 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +ops +jsonschema +cryptography +parameterized diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..30b21c6 --- /dev/null +++ b/src/charm.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Retrieves certificates from an ACME server using the HTTP Request dns provider.""" + +import logging +from typing import Dict +from urllib.parse import urlparse + +from charms.acme_client_operator.v0.acme_client import AcmeClient # type: ignore[import] +from ops.main import main +from ops.model import ActiveStatus, BlockedStatus + +logger = logging.getLogger(__name__) + + +class HTTPReqAcmeOperatorCharm(AcmeClient): + """Main class that is instantiated every time an event occurs.""" + + REQUIRED_CONFIG = [ + "HTTPREQ_ENDPOINT", + ] + + def __init__(self, *args): + """Uses the acme_client library to manage events.""" + super().__init__(*args, plugin="httpreq") + self.framework.observe(self.on.config_changed, self._on_config_changed) + + @property + def _httpreq_endpoint(self) -> str: + """Returns HTTP Request endpoint from config.""" + return self.model.config.get("httpreq_endpoint") + + @property + def _httpreq_mode(self) -> str: + """Returns HTTP Request mode from config.""" + return self.model.config.get("httpreq_mode") + + @property + def _httpreq_http_timeout(self) -> str: + """Returns HTTP Request http timeout from config.""" + return self.model.config.get("httpreq_http_timeout") + + @property + def _httpreq_password(self) -> str: + """Returns HTTP Request password from config.""" + return self.model.config.get("httpreq_password") + + @property + def _httpreq_polling_interval(self) -> str: + """Returns HTTP Request polling interval from config.""" + return self.model.config.get("httpreq_polling_interval") + + @property + def _httpreq_propagation_timeout(self) -> str: + """Returns HTTP Request propagation timeout from config.""" + return self.model.config.get("httpreq_propagation_timeout") + + @property + def _httpreq_username(self) -> str: + """Returns HTTP Request username from config.""" + return self.model.config.get("httpreq_username") + + @property + def _plugin_config(self) -> Dict[str, str]: + """Plugin specific additional configuration for the command.""" + additional_config = { + "HTTPREQ_ENDPOINT": self._httpreq_endpoint, + } + if self._httpreq_http_timeout: + additional_config["HTTPREQ_HTTP_TIMEOUT"] = self._httpreq_http_timeout + if self._httpreq_mode: + additional_config["HTTPREQ_MODE"] = self._httpreq_mode + if self._httpreq_password: + additional_config["HTTPREQ_PASSWORD"] = self._httpreq_password + if self._httpreq_polling_interval: + additional_config["HTTPREQ_POLLING_INTERVAL"] = self._httpreq_polling_interval + if self._httpreq_propagation_timeout: + additional_config["HTTPREQ_PROPAGATION_TIMEOUT"] = self._httpreq_propagation_timeout + if self._httpreq_username: + additional_config["HTTPREQ_USERNAME"] = self._httpreq_username + return additional_config + + def _on_config_changed(self, _) -> None: + """Handles config-changed events.""" + if not self._validate_httpreq_config(): + return + if not self.validate_generic_acme_config(): + return + self.unit.status = ActiveStatus() + + def _validate_httpreq_config(self) -> bool: + """Checks whether required config options are set. + + Returns: + bool: True/False + """ + try: + url = urlparse(self._httpreq_endpoint) + if url.scheme not in ["http", "https"]: + self.unit.status = BlockedStatus( + "HTTPREQ_ENDPOINT must be a valid HTTP or HTTPS URL." + ) + return False + except ValueError: + self.unit.status = BlockedStatus("HTTPREQ_ENDPOINT must be a valid HTTP or HTTPS URL.") + return False + if self._httpreq_mode and self._httpreq_mode != "RAW": + self.unit.status = BlockedStatus("HTTPREQ_MODE must be RAW or not provided.") + return False + return True + + +if __name__ == "__main__": # pragma: nocover + main(HTTPReqAcmeOperatorCharm) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py new file mode 100644 index 0000000..a14942e --- /dev/null +++ b/tests/integration/test_charm.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + + +import logging +from pathlib import Path + +import pytest +import yaml + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +APP_NAME = METADATA["name"] + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test): + """Build the charm-under-test and deploy it together with related charms. + + Assert on the unit status before any relations/configurations take place. + """ + charm = await ops_test.build_charm(".") + resources = {"lego-image": METADATA["resources"]["lego-image"]["upstream-source"]} + await ops_test.model.deploy( + charm, + resources=resources, + application_name=APP_NAME, + series="jammy", + config={ + "email": "example@email.com", + "httpreq_endpoint": "http://dummy.url.com", + }, + ) + + await ops_test.model.wait_for_idle( + apps=[APP_NAME], + status="active", + raise_on_blocked=True, + timeout=1000, + ) + assert ops_test.model.applications[APP_NAME].units[0].workload_status == "active" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py new file mode 100644 index 0000000..234dbe0 --- /dev/null +++ b/tests/unit/test_charm.py @@ -0,0 +1,123 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +# +# Learn more about testing at: https://juju.is/docs/sdk/testing + +import unittest + +from ops.model import ActiveStatus, BlockedStatus +from ops.testing import Harness +from parameterized import parameterized # type: ignore[import] + +from charm import HTTPReqAcmeOperatorCharm + + +class TestCharm(unittest.TestCase): + def setUp(self): + self.harness = Harness(HTTPReqAcmeOperatorCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def test_given_email_is_valid_when_config_changed_then_status_is_active(self): + self.harness.update_config( + { + "email": "example@email.com", + "httpreq_endpoint": "http://dummy.url.com", + } + ) + self.assertEqual(self.harness.model.unit.status, ActiveStatus()) + + def test_given_email_is_invalid_when_config_changed_then_status_is_blocked(self): + self.harness.update_config( + { + "email": "invalid-email", + "httpreq_endpoint": "http://dummy.url.com", + } + ) + self.assertEqual(self.harness.model.unit.status, BlockedStatus("Invalid email address")) + + @parameterized.expand( + [ + ( + "HTTPREQ_ENDPOINT", + { + "email": "example@email.com", + "httpreq_endpoint": "dummy.url.com", + }, + ), + ( + "HTTPREQ_ENDPOINT", + { + "email": "example@email.com", + "httpreq_endpoint": "@BADdummy", + }, + ), + ( + "HTTPREQ_ENDPOINT", + { + "email": "example@email.com", + "httpreq_endpoint": "ftp://dummy.url.com", + }, + ), + ] + ) + def test_given_bad_urls_when_config_changed_then_status_is_blocked(self, option, config): + self.harness.update_config(config) + self.assertEqual( + self.harness.model.unit.status, + BlockedStatus("HTTPREQ_ENDPOINT must be a valid HTTP or HTTPS URL."), + ) + + def test_optional_config_provided_then_status_is_active(self): + self.harness.update_config( + { + "email": "example@email.com", + "httpreq_endpoint": "http://dummy.url.com", + "httpreq_http_timeout": 5, + "httpreq_mode": "RAW", + "httpreq_password": "qwerty123", + "httpreq_polling_interval": 30, + "httpreq_propagation_timeout": 10, + "httpreq_username": "bob", + } + ) + self.assertEqual(self.harness.model.unit.status, ActiveStatus()) + + def test_optional_config_provided_then_plugin_config_is_correct(self): + self.harness.update_config( + { + "email": "example@email.com", + "httpreq_endpoint": "http://dummy.url.com", + "httpreq_http_timeout": 5, + "httpreq_mode": "RAW", + "httpreq_password": "qwerty123", + "httpreq_polling_interval": 30, + "httpreq_propagation_timeout": 10, + "httpreq_username": "bob", + } + ) + self.assertEqual( + self.harness.charm._plugin_config, + { + "HTTPREQ_ENDPOINT": "http://dummy.url.com", + "HTTPREQ_HTTP_TIMEOUT": 5, + "HTTPREQ_MODE": "RAW", + "HTTPREQ_PASSWORD": "qwerty123", + "HTTPREQ_POLLING_INTERVAL": 30, + "HTTPREQ_PROPAGATION_TIMEOUT": 10, + "HTTPREQ_USERNAME": "bob", + }, + ) + + def test_invalid_mode_config_provided_then_status_is_blocked(self): + self.harness.update_config( + { + "email": "example@email.com", + "httpreq_endpoint": "http://dummy.url.com", + "httpreq_mode": "MAGIC", + } + ) + self.assertEqual( + self.harness.model.unit.status, + BlockedStatus("HTTPREQ_MODE must be RAW or not provided."), + ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..4984afc --- /dev/null +++ b/tox.ini @@ -0,0 +1,86 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +[tox] +skipsdist=True +skip_missing_interpreters = True +envlist = lint, static, unit + +[vars] +src_path = {toxinidir}/src/ +unit_test_path = {toxinidir}/tests/unit/ +integration_test_path = {toxinidir}/tests/integration/ +all_path = {[vars]src_path} {[vars]unit_test_path} + +[testenv] +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} + PYTHONBREAKPOINT=ipdb.set_trace + PY_COLORS=1 +passenv = + PYTHONPATH + CHARM_BUILD_DIR + MODEL_SETTINGS + +[testenv:fmt] +description = Apply coding style standards to code +deps = + black + isort +commands = + isort {[vars]all_path} + black {[vars]all_path} + +[testenv:lint] +description = Check code against coding style standards +deps = + black + flake8 == 4.0.1 + flake8-docstrings + flake8-copyright + flake8-builtins + pyproject-flake8 + pep8-naming + isort + codespell +commands = + codespell {[vars]all_path} + pflake8 {[vars]all_path} + isort --check-only --diff {[vars]all_path} + black --check --diff {[vars]all_path} + +[testenv:static] +description = Run static analysis checks +deps = + -r{toxinidir}/requirements.txt + mypy + types-PyYAML + pytest + pytest-operator + juju + types-setuptools + types-toml +setenv = + PYTHONPATH = "" +commands = + mypy {[vars]all_path} {posargs} + +[testenv:unit] +description = Run unit tests +deps = + pytest + coverage[toml] + -r{toxinidir}/requirements.txt +commands = + coverage run --source={[vars]src_path} -m pytest {[vars]unit_test_path} -v --tb native -s {posargs} + coverage report + +[testenv:integration] +description = Run integration tests +deps = + pytest + juju<3.1 + pytest-operator + -r{toxinidir}/requirements.txt +commands = + pytest --asyncio-mode=auto -v --tb native {[vars]integration_test_path} --log-cli-level=INFO -s {posargs}