Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
432 changes: 432 additions & 0 deletions lib/charms/certificate_transfer_interface/v0/certificate_transfer.py

Large diffs are not rendered by default.

571 changes: 571 additions & 0 deletions lib/charms/glauth_k8s/v0/ldap.py

Large diffs are not rendered by default.

87 changes: 75 additions & 12 deletions lib/charms/postgresql_k8s/v0/postgresql_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
This class handles certificate request and renewal through
the interaction with the TLS Certificates Operator.

This library needs that https://charmhub.io/tls-certificates-interface/libraries/tls_certificates
library is imported to work.
This library needs that the following libraries are imported to work:
- https://charmhub.io/certificate-transfer-interface/libraries/certificate_transfer
- https://charmhub.io/tls-certificates-interface/libraries/tls_certificates

It also needs the following methods in the charm class:
— get_hostname_by_unit: to retrieve the DNS hostname of the unit.
Expand All @@ -24,6 +25,15 @@
import socket
from typing import List, Optional

from charms.certificate_transfer_interface.v0.certificate_transfer import (
CertificateAvailableEvent as CertificateAddedEvent,
)
from charms.certificate_transfer_interface.v0.certificate_transfer import (
CertificateRemovedEvent as CertificateRemovedEvent,
)
from charms.certificate_transfer_interface.v0.certificate_transfer import (
CertificateTransferRequires,
)
from charms.tls_certificates_interface.v2.tls_certificates import (
CertificateAvailableEvent,
CertificateExpiringEvent,
Expand All @@ -45,11 +55,12 @@

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version.
LIBPATCH = 13
LIBPATCH = 14

logger = logging.getLogger(__name__)
SCOPE = "unit"
TLS_RELATION = "certificates"
TLS_CREATION_RELATION = "certificates"
TLS_TRANSFER_RELATION = "receive-ca-cert"


class PostgreSQLTLS(Object):
Expand All @@ -63,18 +74,29 @@ def __init__(
self.charm = charm
self.peer_relation = peer_relation
self.additional_dns_names = additional_dns_names or []
self.certs = TLSCertificatesRequiresV2(self.charm, TLS_RELATION)
self.certs_creation = TLSCertificatesRequiresV2(self.charm, TLS_CREATION_RELATION)
self.certs_transfer = CertificateTransferRequires(self.charm, TLS_TRANSFER_RELATION)
self.framework.observe(
self.charm.on.set_tls_private_key_action, self._on_set_tls_private_key
)
self.framework.observe(
self.charm.on[TLS_RELATION].relation_joined, self._on_tls_relation_joined
self.charm.on[TLS_CREATION_RELATION].relation_joined, self._on_tls_relation_joined
)
self.framework.observe(
self.charm.on[TLS_CREATION_RELATION].relation_broken, self._on_tls_relation_broken
)
self.framework.observe(
self.certs_creation.on.certificate_available, self._on_certificate_available
)
self.framework.observe(
self.certs_creation.on.certificate_expiring, self._on_certificate_expiring
)
self.framework.observe(
self.charm.on[TLS_RELATION].relation_broken, self._on_tls_relation_broken
self.certs_transfer.on.certificate_available, self._on_certificate_added
)
self.framework.observe(
self.certs_transfer.on.certificate_removed, self._on_certificate_removed
)
self.framework.observe(self.certs.on.certificate_available, self._on_certificate_available)
self.framework.observe(self.certs.on.certificate_expiring, self._on_certificate_expiring)

def _on_set_tls_private_key(self, event: ActionEvent) -> None:
"""Set the TLS private key, which will be used for requesting the certificate."""
Expand All @@ -93,8 +115,8 @@ def _request_certificate(self, param: Optional[str]):
self.charm.set_secret(SCOPE, "key", key.decode("utf-8"))
self.charm.set_secret(SCOPE, "csr", csr.decode("utf-8"))

if self.charm.model.get_relation(TLS_RELATION):
self.certs.request_certificate_creation(certificate_signing_request=csr)
if self.charm.model.get_relation(TLS_CREATION_RELATION):
self.certs_creation.request_certificate_creation(certificate_signing_request=csr)

@staticmethod
def _parse_tls_file(raw_content: str) -> bytes:
Expand All @@ -117,6 +139,7 @@ def _on_tls_relation_broken(self, event: RelationBrokenEvent) -> None:
self.charm.set_secret(SCOPE, "ca", None)
self.charm.set_secret(SCOPE, "cert", None)
self.charm.set_secret(SCOPE, "chain", None)

if not self.charm.update_config():
logger.debug("Cannot update config at this moment")
event.defer()
Expand Down Expand Up @@ -163,12 +186,52 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None:
subject=self.charm.get_hostname_by_unit(self.charm.unit.name),
**self._get_sans(),
)
self.certs.request_certificate_renewal(
self.certs_creation.request_certificate_renewal(
old_certificate_signing_request=old_csr,
new_certificate_signing_request=new_csr,
)
self.charm.set_secret(SCOPE, "csr", new_csr.decode("utf-8"))

def _on_certificate_added(self, event: CertificateAddedEvent) -> None:
"""Enable TLS when TLS certificate is added."""
relation = self.charm.model.get_relation(TLS_TRANSFER_RELATION, event.relation_id)
if relation is None:
logger.error("Relationship not established anymore.")
return

secret_name = f"ca-{relation.app.name}"
self.charm.set_secret(SCOPE, secret_name, event.ca)

try:
if not self.charm.push_ca_file_into_workload(secret_name):
logger.debug("Cannot push TLS certificates at this moment")
event.defer()
return
except (PebbleConnectionError, PathError, ProtocolError, RetryError) as e:
logger.error("Cannot push TLS certificates: %r", e)
event.defer()
return

def _on_certificate_removed(self, event: CertificateRemovedEvent) -> None:
"""Disable TLS when TLS certificate is removed."""
relation = self.charm.model.get_relation(TLS_TRANSFER_RELATION, event.relation_id)
if relation is None:
logger.error("Relationship not established anymore.")
return

secret_name = f"ca-{relation.app.name}"
self.charm.set_secret(SCOPE, secret_name, None)

try:
if not self.charm.clean_ca_file_from_workload(secret_name):
logger.debug("Cannot clean CA certificates at this moment")
event.defer()
return
except (PebbleConnectionError, PathError, ProtocolError, RetryError) as e:
logger.error("Cannot clean CA certificates: %r", e)
event.defer()
return

def _get_sans(self) -> dict:
"""Create a list of Subject Alternative Names for a PostgreSQL unit.

Expand Down
7 changes: 7 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,17 @@ requires:
interface: tls-certificates
limit: 1
optional: true
receive-ca-cert:
interface: certificate_transfer
optional: true
s3-parameters:
interface: s3
limit: 1
optional: true
ldap:
interface: ldap
limit: 1
optional: true
tracing:
interface: tracing
limit: 1
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pydantic = "^1.10"
cosl = ">=0.0.50"
# tls_certificates_interface/v2/tls_certificates.py
cryptography = "*"
# certificate_transfer_interface/v0/certificate_transfer.py
# tls_certificates_interface/v2/tls_certificates.py
jsonschema = "*"
# tempo_coordinator_k8s/v0/charm_tracing.py
opentelemetry-exporter-otlp-proto-http = "1.21.0"
Expand Down
30 changes: 30 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
TLS_KEY_FILE,
TRACING_PROTOCOL,
UNIT_SCOPE,
UPDATE_CERTS_BIN_PATH,
USER,
USER_PASSWORD_KEY,
)
Expand Down Expand Up @@ -190,6 +191,8 @@ def __init__(self, *args):
self.framework.observe(self.on.update_status, self._on_update_status)
self.cluster_name = self.app.name
self._member_name = self.unit.name.replace("/", "-")

self._certs_path = "/usr/local/share/ca-certificates"
self._storage_path = self.meta.storages["pgdata"].location

self.upgrade = PostgreSQLUpgrade(
Expand Down Expand Up @@ -1797,6 +1800,33 @@ def push_tls_files_to_workload(self) -> bool:
logger.exception("TLS files failed to push. Error in config update")
return False

def push_ca_file_into_workload(self, secret_name: str) -> bool:
"""Move CA certificates file into the PostgreSQL storage path."""
certs = self.get_secret(UNIT_SCOPE, secret_name)
if certs is not None:
certs_file = Path(self._certs_path, f"{secret_name}.crt")
certs_file.write_text(certs)
subprocess.check_call([UPDATE_CERTS_BIN_PATH]) # noqa: S603

try:
return self.update_config()
except Exception:
logger.exception("CA file failed to push. Error in config update")
return False

def clean_ca_file_from_workload(self, secret_name: str) -> bool:
"""Cleans up CA certificates from the PostgreSQL storage path."""
certs_file = Path(self._certs_path, f"{secret_name}.crt")
certs_file.unlink()

subprocess.check_call([UPDATE_CERTS_BIN_PATH]) # noqa: S603

try:
return self.update_config()
except Exception:
logger.exception("CA file failed to clean. Error in config update")
return False

def _reboot_on_detached_storage(self, event: EventBase) -> None:
"""Reboot on detached storage.

Expand Down
2 changes: 2 additions & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
POSTGRESQL_DATA_PATH = f"{SNAP_DATA_PATH}/postgresql"
POSTGRESQL_LOGS_PATH = f"{SNAP_LOGS_PATH}/postgresql"

UPDATE_CERTS_BIN_PATH = "/usr/sbin/update-ca-certificates"

PGBACKREST_CONFIGURATION_FILE = f"--config={PGBACKREST_CONF_PATH}/pgbackrest.conf"

METRICS_PORT = "9187"
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1131,7 +1131,9 @@ async def backup_operations(
config={"profile": "testing"},
)

await ops_test.model.relate(database_app_name, tls_certificates_app_name)
await ops_test.model.relate(
f"{database_app_name}:certificates", f"{tls_certificates_app_name}:certificates"
)
async with ops_test.fast_forward(fast_interval="60s"):
await ops_test.model.wait_for_idle(apps=[database_app_name], status="active", timeout=1000)

Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_backups_pitr_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ async def pitr_backup_operations(
logger.info(
"integrating self-signed-certificates with postgresql and waiting them to stabilize"
)
await ops_test.model.relate(database_app_name, tls_certificates_app_name)
await ops_test.model.relate(
f"{database_app_name}:certificates", f"{tls_certificates_app_name}:certificates"
)
async with ops_test.fast_forward(fast_interval="60s"):
await ops_test.model.wait_for_idle(
apps=[database_app_name, tls_certificates_app_name], status="active", timeout=1000
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_backups_pitr_gcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ async def pitr_backup_operations(
logger.info(
"integrating self-signed-certificates with postgresql and waiting them to stabilize"
)
await ops_test.model.relate(database_app_name, tls_certificates_app_name)
await ops_test.model.relate(
f"{database_app_name}:certificates", f"{tls_certificates_app_name}:certificates"
)
async with ops_test.fast_forward(fast_interval="60s"):
await ops_test.model.wait_for_idle(
apps=[database_app_name, tls_certificates_app_name], status="active", timeout=1000
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ async def test_tls_enabled(ops_test: OpsTest) -> None:
)

# Relate it to the PostgreSQL to enable TLS.
await ops_test.model.relate(DATABASE_APP_NAME, tls_certificates_app_name)
await ops_test.model.relate(
f"{DATABASE_APP_NAME}:certificates", f"{tls_certificates_app_name}:certificates"
)
await ops_test.model.wait_for_idle(status="active", timeout=1500, raise_on_error=False)

# Wait for all units enabling TLS.
Expand Down
44 changes: 43 additions & 1 deletion tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@
SwitchoverFailedError,
SwitchoverNotSyncError,
)
from constants import PEER, POSTGRESQL_SNAP_NAME, SECRET_INTERNAL_LABEL, SNAP_PACKAGES
from constants import (
PEER,
POSTGRESQL_SNAP_NAME,
SECRET_INTERNAL_LABEL,
SNAP_PACKAGES,
UPDATE_CERTS_BIN_PATH,
)

CREATE_CLUSTER_CONF_PATH = "/etc/postgresql-common/createcluster.d/pgcharm.conf"

Expand Down Expand Up @@ -1766,6 +1772,42 @@ def test_push_tls_files_to_workload(harness):
assert _render_file.call_count == 2


def test_push_ca_file_into_workload(harness):
with (
patch("charm.PostgresqlOperatorCharm.update_config") as _update_config,
patch("pathlib.Path.write_text") as _write_text,
patch("subprocess.check_call") as _check_call,
):
harness.charm.set_secret("unit", "ca-app", "test-ca")

assert harness.charm.push_ca_file_into_workload("ca-app")
_write_text.assert_called_once()
_check_call.assert_called_once_with([UPDATE_CERTS_BIN_PATH])
_update_config.assert_called_once()


def test_clean_ca_file_from_workload(harness):
with (
patch("charm.PostgresqlOperatorCharm.update_config") as _update_config,
patch("pathlib.Path.write_text") as _write_text,
patch("pathlib.Path.unlink") as _unlink,
patch("subprocess.check_call") as _check_call,
):
harness.charm.set_secret("unit", "ca-app", "test-ca")

assert harness.charm.push_ca_file_into_workload("ca-app")
_write_text.assert_called_once()
_check_call.assert_called_once_with([UPDATE_CERTS_BIN_PATH])
_update_config.assert_called_once()

_check_call.reset_mock()
_update_config.reset_mock()

assert harness.charm.clean_ca_file_from_workload("ca-app")
_unlink.assert_called_once()
_check_call.assert_called_once_with([UPDATE_CERTS_BIN_PATH])


def test_is_workload_running(harness):
with patch("charm.snap.SnapCache") as _snap_cache:
pg_snap = _snap_cache.return_value[POSTGRESQL_SNAP_NAME]
Expand Down