Skip to content

Commit

Permalink
TLS implementation (#36)
Browse files Browse the repository at this point in the history
* Import files

* Add TLS implementation

* Update tests/unit/test_postgresql_tls.py

Co-authored-by: Will Fitch <WRFitch@outlook.com>

* Add required functions and variable for regex

* Improve TLS check

Co-authored-by: Will Fitch <WRFitch@outlook.com>
  • Loading branch information
marceloneppel and WRFitch authored Sep 8, 2022
1 parent d20f264 commit 347dce7
Show file tree
Hide file tree
Showing 10 changed files with 432 additions and 55 deletions.
6 changes: 6 additions & 0 deletions actions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ set-password:
password:
type: string
description: The password will be auto-generated if this option is not specified.
set-tls-private-key:
description: Set the private key, which will be used for certificate signing requests (CSR). Run for each unit separately.
params:
private-key:
type: string
description: The content of private key for communications with clients. Content will be auto-generated if this option is not specified.
16 changes: 15 additions & 1 deletion lib/charms/postgresql_k8s/v0/postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

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


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -178,6 +178,20 @@ def get_postgresql_version(self) -> str:
logger.error(f"Failed to get PostgreSQL version: {e}")
raise PostgreSQLGetPostgreSQLVersionError()

def is_tls_enabled(self) -> bool:
"""Returns whether TLS is enabled.
Returns:
whether TLS is enabled.
"""
try:
with self._connect_to_database() as connection, connection.cursor() as cursor:
cursor.execute("SHOW ssl;")
return "on" in cursor.fetchone()[0]
except psycopg2.Error as e:
logger.error(f"Failed to get check whether TLS is enabled: {e}")
return False

def update_user_password(self, username: str, password: str) -> None:
"""Update a user password.
Expand Down
126 changes: 107 additions & 19 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
PostgreSQL,
PostgreSQLUpdateUserPasswordError,
)
from charms.postgresql_k8s.v0.postgresql_tls import PostgreSQLTLS
from charms.rolling_ops.v0.rollingops import RollingOpsManager
from lightkube import ApiError, Client, codecs
from lightkube.resources.core_v1 import Endpoints, Pod, Service
from ops.charm import (
Expand All @@ -25,11 +27,12 @@
from ops.model import (
ActiveStatus,
BlockedStatus,
Container,
MaintenanceStatus,
Relation,
WaitingStatus,
)
from ops.pebble import Layer
from ops.pebble import Layer, PathError, ProtocolError
from requests import ConnectionError
from tenacity import RetryError

Expand All @@ -38,8 +41,13 @@
REPLICATION_PASSWORD_KEY,
REPLICATION_USER,
SYSTEM_USERS,
TLS_CA_FILE,
TLS_CERT_FILE,
TLS_KEY_FILE,
USER,
USER_PASSWORD_KEY,
WORKLOAD_OS_GROUP,
WORKLOAD_OS_USER,
)
from patroni import NotReadyError, Patroni
from relations.db import DbProvides
Expand Down Expand Up @@ -78,6 +86,10 @@ def __init__(self, *args):
self.postgresql_client_relation = PostgreSQLProvider(self)
self.legacy_db_relation = DbProvides(self, admin=False)
self.legacy_db_admin_relation = DbProvides(self, admin=True)
self.tls = PostgreSQLTLS(self, PEER)
self.restart_manager = RollingOpsManager(
charm=self, relation="restart", callback=self._restart
)

@property
def app_peer_data(self) -> Dict:
Expand All @@ -97,7 +109,7 @@ def unit_peer_data(self) -> Dict:

return relation.data[self.unit]

def _get_secret(self, scope: str, key: str) -> Optional[str]:
def get_secret(self, scope: str, key: str) -> Optional[str]:
"""Get secret from the secret storage."""
if scope == "unit":
return self.unit_peer_data.get(key, None)
Expand All @@ -106,7 +118,7 @@ def _get_secret(self, scope: str, key: str) -> Optional[str]:
else:
raise RuntimeError("Unknown secret scope.")

def _set_secret(self, scope: str, key: str, value: Optional[str]) -> None:
def set_secret(self, scope: str, key: str, value: Optional[str]) -> None:
"""Get secret from the secret storage."""
if scope == "unit":
if not value:
Expand All @@ -127,7 +139,7 @@ def postgresql(self) -> PostgreSQL:
return PostgreSQL(
host=self.primary_endpoint,
user=USER,
password=self._get_secret("app", f"{USER}-password"),
password=self.get_secret("app", f"{USER}-password"),
database="postgres",
)

Expand All @@ -150,6 +162,18 @@ def _build_service_name(self, service: str) -> str:
"""Build a full k8s service name based on the service name."""
return f"{self._name}-{service}.{self._namespace}.svc.cluster.local"

def get_hostname_by_unit(self, unit_name: str) -> str:
"""Create a DNS name for a PostgreSQL unit.
Args:
unit_name: the juju unit name, e.g. "postgre-sql/1".
Returns:
A string representing the hostname of the PostgreSQL unit.
"""
unit_id = unit_name.split("/")[1]
return f"{self.app.name}-{unit_id}.{self.app.name}-endpoints"

def _get_endpoints_to_remove(self) -> List[str]:
"""List the endpoints that were part of the cluster but departed."""
old = self._endpoints
Expand Down Expand Up @@ -194,7 +218,7 @@ def _on_peer_relation_changed(self, event: RelationChangedEvent) -> None:

# Update the list of the cluster members in the replicas to make them know each other.
# Update the cluster members in this unit (updating patroni configuration).
self._patroni.update_cluster_members()
self.update_config()

# Validate the status of the member before setting an ActiveStatus.
if not self._patroni.member_started:
Expand Down Expand Up @@ -301,11 +325,11 @@ def _get_hostname_from_unit(self, member: str) -> str:

def _on_leader_elected(self, event: LeaderElectedEvent) -> None:
"""Handle the leader-elected event."""
if self._get_secret("app", USER_PASSWORD_KEY) is None:
self._set_secret("app", USER_PASSWORD_KEY, new_password())
if self.get_secret("app", USER_PASSWORD_KEY) is None:
self.set_secret("app", USER_PASSWORD_KEY, new_password())

if self._get_secret("app", REPLICATION_PASSWORD_KEY) is None:
self._set_secret("app", REPLICATION_PASSWORD_KEY, new_password())
if self.get_secret("app", REPLICATION_PASSWORD_KEY) is None:
self.set_secret("app", REPLICATION_PASSWORD_KEY, new_password())

# Create resources and add labels needed for replication.
self._create_resources()
Expand Down Expand Up @@ -345,6 +369,13 @@ def _on_postgresql_pebble_ready(self, event: WorkloadEvent) -> None:
event.defer()
return

try:
self.push_tls_files_to_workload(container)
except (PathError, ProtocolError) as e:
logger.error("Cannot push TLS certificates: %r", e)
event.defer()
return

# Get the current layer.
current_layer = container.get_plan()
# Check if there are any changes to layer services.
Expand All @@ -354,7 +385,6 @@ def _on_postgresql_pebble_ready(self, event: WorkloadEvent) -> None:
logging.info("Added updated layer 'postgresql' to Pebble plan")
# TODO: move this file generation to on config changed hook
# when adding configs to this charm.
self._patroni.render_patroni_yml_file()
# Restart it and report a new status to Juju.
container.restart(self._postgresql_service)
logging.info("Restarted postgresql service")
Expand Down Expand Up @@ -447,9 +477,7 @@ def _on_get_password(self, event: ActionEvent) -> None:
f" {', '.join(SYSTEM_USERS)} not {username}"
)
return
event.set_results(
{f"{username}-password": self._get_secret("app", f"{username}-password")}
)
event.set_results({f"{username}-password": self.get_secret("app", f"{username}-password")})

def _on_set_password(self, event: ActionEvent) -> None:
"""Set the password for the specified user."""
Expand All @@ -470,7 +498,7 @@ def _on_set_password(self, event: ActionEvent) -> None:
if "password" in event.params:
password = event.params["password"]

if password == self._get_secret("app", f"{username}-password"):
if password == self.get_secret("app", f"{username}-password"):
event.log("The old and new passwords are equal.")
event.set_results({f"{username}-password": password})
return
Expand All @@ -495,12 +523,11 @@ def _on_set_password(self, event: ActionEvent) -> None:
return

# Update the password in the secret store.
self._set_secret("app", f"{username}-password", password)
self.set_secret("app", f"{username}-password", password)

# Update and reload Patroni configuration in this unit to use the new password.
# Other units Patroni configuration will be reloaded in the peer relation changed event.
self._patroni.render_patroni_yml_file()
self._patroni.reload_patroni_configuration()
self.update_config()

event.set_results({f"{username}-password": password})

Expand Down Expand Up @@ -569,8 +596,8 @@ def _patroni(self):
self._namespace,
self.app.planned_units(),
self._storage_path,
self._get_secret("app", USER_PASSWORD_KEY),
self._get_secret("app", REPLICATION_PASSWORD_KEY),
self.get_secret("app", USER_PASSWORD_KEY),
self.get_secret("app", REPLICATION_PASSWORD_KEY),
)

@property
Expand Down Expand Up @@ -649,6 +676,67 @@ def _peers(self) -> Relation:
"""
return self.model.get_relation(PEER)

def push_tls_files_to_workload(self, container: Container = None) -> None:
"""Uploads TLS files to the workload container."""
if container is None:
container = self.unit.get_container("postgresql")

key, ca, cert = self.tls.get_tls_files()
if key is not None:
container.push(
f"{self._storage_path}/{TLS_KEY_FILE}",
key,
make_dirs=True,
permissions=0o400,
user=WORKLOAD_OS_USER,
group=WORKLOAD_OS_GROUP,
)
if ca is not None:
container.push(
f"{self._storage_path}/{TLS_CA_FILE}",
ca,
make_dirs=True,
permissions=0o400,
user=WORKLOAD_OS_USER,
group=WORKLOAD_OS_GROUP,
)
if cert is not None:
container.push(
f"{self._storage_path}/{TLS_CERT_FILE}",
cert,
make_dirs=True,
permissions=0o400,
user=WORKLOAD_OS_USER,
group=WORKLOAD_OS_GROUP,
)

self.update_config()

def _restart(self, _) -> None:
"""Restart PostgreSQL."""
try:
self._patroni.restart_postgresql()
except RetryError as e:
logger.error("failed to restart PostgreSQL")
self.unit.status = BlockedStatus(f"failed to restart PostgreSQL with error {e}")

def update_config(self) -> None:
"""Updates Patroni config file based on the existence of the TLS files."""
enable_tls = all(self.tls.get_tls_files())

# Update and reload configuration based on TLS files availability.
self._patroni.render_patroni_yml_file(enable_tls=enable_tls)
if not self._patroni.member_started:
return

restart_postgresql = enable_tls != self.postgresql.is_tls_enabled()
self._patroni.reload_patroni_configuration()

# Restart PostgreSQL if TLS configuration has changed
# (so the both old and new connections use the configuration).
if restart_postgresql:
self.on[self.restart_manager.name].acquire_lock.emit()

def _unit_name_to_pod_name(self, unit_name: str) -> str:
"""Converts unit name to pod name.
Expand Down
5 changes: 5 additions & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
PEER = "database-peers"
REPLICATION_USER = "replication"
REPLICATION_PASSWORD_KEY = "replication-password"
TLS_KEY_FILE = "key.pem"
TLS_CA_FILE = "ca.pem"
TLS_CERT_FILE = "cert.pem"
USER = "operator"
USER_PASSWORD_KEY = "operator-password"
WORKLOAD_OS_GROUP = "postgres"
WORKLOAD_OS_USER = "postgres"
# List of system usernames needed for correct work of the charm/workload.
SYSTEM_USERS = [REPLICATION_USER, USER]
28 changes: 12 additions & 16 deletions src/patroni.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,18 @@ def _render_file(self, path: str, content: str, mode: int) -> None:
# Ignore non existing user error when it wasn't created yet.
pass

def render_patroni_yml_file(self) -> None:
"""Render the Patroni configuration file."""
def render_patroni_yml_file(self, enable_tls: bool = False) -> None:
"""Render the Patroni configuration file.
Args:
enable_tls: whether to enable TLS.
"""
# Open the template postgresql.conf file.
with open("templates/patroni.yml.j2", "r") as file:
template = Template(file.read())
# Render the template file with the correct values.
rendered = template.render(
enable_tls=enable_tls,
endpoint=self._endpoint,
endpoints=self._endpoints,
namespace=self._namespace,
Expand All @@ -165,21 +170,12 @@ def render_postgresql_conf_file(self) -> None:
)
self._render_file(f"{self._storage_path}/postgresql-k8s-operator.conf", rendered, 0o644)

def update_cluster_members(self) -> None:
"""Update the list of members of the cluster."""
# Update the members in the Patroni configuration.
self.render_patroni_yml_file()

try:
if self.member_started:
# Make Patroni use the updated configuration.
self.reload_patroni_configuration()
except RetryError:
# Ignore retry errors that happen when the member has not started yet.
# The configuration will be loaded correctly when Patroni starts.
pass

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def reload_patroni_configuration(self) -> None:
"""Reloads the configuration after it was updated in the file."""
requests.post(f"http://{self._endpoint}:8008/reload")

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def restart_postgresql(self) -> None:
"""Restart PostgreSQL."""
requests.post(f"http://{self._endpoint}:8008/restart")
17 changes: 12 additions & 5 deletions templates/patroni.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ bootstrap:
- locale: en_US.UTF-8
- data-checksums
pg_hba:
- host all all 0.0.0.0/0 md5
- host replication replication 127.0.0.1/32 md5
- {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 md5
- {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 md5
bypass_api_service: true
log:
dir: /var/log/postgresql
Expand All @@ -23,12 +23,19 @@ postgresql:
custom_conf: {{ storage_path }}/postgresql-k8s-operator.conf
data_dir: {{ storage_path }}/pgdata
listen: 0.0.0.0:5432
{%- if enable_tls %}
parameters:
ssl: on
ssl_ca_file: {{ storage_path }}/ca.pem
ssl_cert_file: {{ storage_path }}/cert.pem
ssl_key_file: {{ storage_path }}/key.pem
{%- endif %}
pgpass: /tmp/pgpass
pg_hba:
- host all all 0.0.0.0/0 md5
- host replication replication 127.0.0.1/32 md5
- {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 md5
- {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 md5
{%- for endpoint in endpoints %}
- host replication replication {{ endpoint }}.{{ namespace }}.svc.cluster.local md5
- {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ endpoint }}.{{ namespace }}.svc.cluster.local md5
{%- endfor %}
authentication:
replication:
Expand Down
Loading

0 comments on commit 347dce7

Please sign in to comment.