Skip to content

Commit

Permalink
TLS implementation (canonical#30)
Browse files Browse the repository at this point in the history
* Add TLS implementation

* Delete file

* Update library

* Fix PostgreSQL library

* Add jsonschema as a binary dependency

* Change hostname to unit ip

* Add unit test dependency

* Call certificate update on config change

* Fix docstring

* Change log call
  • Loading branch information
marceloneppel authored Sep 19, 2022
1 parent 710fbde commit 3a1dc76
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 70 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.
109 changes: 89 additions & 20 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
PostgreSQLCreateUserError,
PostgreSQLUpdateUserPasswordError,
)
from charms.postgresql_k8s.v0.postgresql_tls import PostgreSQLTLS
from charms.rolling_ops.v0.rollingops import RollingOpsManager
from ops.charm import (
ActionEvent,
CharmBase,
Expand Down Expand Up @@ -45,6 +47,9 @@
PEER,
REPLICATION_PASSWORD_KEY,
SYSTEM_USERS,
TLS_CA_FILE,
TLS_CERT_FILE,
TLS_KEY_FILE,
USER,
USER_PASSWORD_KEY,
)
Expand Down Expand Up @@ -83,6 +88,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 @@ -102,7 +111,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 @@ -111,7 +120,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 @@ -133,7 +142,7 @@ def postgresql(self) -> PostgreSQL:
primary_host=self.primary_endpoint,
current_host=self._unit_ip,
user=USER,
password=self._get_secret("app", f"{USER}-password"),
password=self.get_secret("app", f"{USER}-password"),
database="postgres",
)

Expand All @@ -155,6 +164,16 @@ def primary_endpoint(self) -> Optional[str]:
else:
return primary_endpoint

def get_hostname_by_unit(self, _) -> str:
"""Create a DNS name for a PostgreSQL unit.
Returns:
A string representing the hostname of the PostgreSQL unit.
"""
# For now, as there is no DNS hostnames on VMs, and it would also depend on
# the underlying provider (LXD, MAAS, etc.), the unit IP is returned.
return self._unit_ip

def _on_get_primary(self, event: ActionEvent) -> None:
"""Get primary instance."""
try:
Expand Down Expand Up @@ -196,7 +215,7 @@ def _on_peer_relation_departed(self, event: RelationDepartedEvent) -> None:

# Update the list of the current members.
self._remove_from_members_ips(member_ip)
self._patroni.update_cluster_members()
self.update_config()

if self.primary_endpoint:
self.postgresql_client_relation.update_endpoints()
Expand Down Expand Up @@ -271,7 +290,7 @@ def _on_peer_relation_changed(self, event: RelationChangedEvent):
# Update the list of the cluster members in the replicas to make them know each other.
try:
# Update the members of the cluster in the Patroni configuration on this unit.
self._patroni.update_cluster_members()
self.update_config()
except RetryError:
self.unit.status = BlockedStatus("failed to update cluster members on member")
return
Expand Down Expand Up @@ -348,7 +367,7 @@ def add_cluster_member(self, member: str) -> None:

# Update Patroni configuration file.
try:
self._patroni.update_cluster_members()
self.update_config()
except RetryError:
self.unit.status = BlockedStatus("failed to update cluster members on member")

Expand Down Expand Up @@ -523,10 +542,10 @@ def _inhibit_default_cluster_creation(self) -> None:
def _on_leader_elected(self, event: LeaderElectedEvent) -> None:
"""Handle the leader-elected event."""
# The leader sets the needed passwords if they weren't set before.
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", 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())

# Update the list of the current PostgreSQL hosts when a new leader is elected.
# Add this unit to the list of cluster members
Expand All @@ -538,7 +557,7 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None:
for ip in self._get_ips_to_remove():
self._remove_from_members_ips(ip)

self._patroni.update_cluster_members()
self.update_config()

# Don't update connection endpoints in the first time this event run for
# this application because there are no primary and replicas yet.
Expand All @@ -564,6 +583,8 @@ def _on_config_changed(self, event: ConfigChangedEvent) -> None:
except (subprocess.CalledProcessError, apt.PackageNotFoundError):
logger.warning("failed to install apts packages")

self._update_certificate()

def _get_ips_to_remove(self) -> Set[str]:
"""List the IPs that were part of the cluster but departed."""
old = self.members_ips
Expand Down Expand Up @@ -635,9 +656,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 @@ -656,7 +675,7 @@ def _on_set_password(self, event: ActionEvent) -> None:

password = event.params.get("password", new_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 @@ -681,12 +700,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 All @@ -696,6 +714,20 @@ def _on_update_status(self, _) -> None:
self.legacy_db_relation.update_endpoints()
self.legacy_db_admin_relation.update_endpoints()
self.postgresql_client_relation.oversee_users()
self._update_certificate()

def _update_certificate(self) -> None:
"""Updates the TLS certificate if the unit IP changes."""
# Update the certificate if the IP changes because the IP
# is used as the hostname in the certificate subject field.
if self.get_hostname_by_unit(None) != self.unit_peer_data.get("ip"):
self.unit_peer_data.update({"ip": self.get_hostname_by_unit(None)})

# Request the certificate only if there is already one. If there isn't,
# the certificate will be generated in the relation joined event when
# relating to the TLS Certificates Operator.
if all(self.tls.get_tls_files()):
self.tls._request_certificate(self.get_secret("unit", "private-key"))

@property
def _has_blocked_status(self) -> bool:
Expand All @@ -709,7 +741,7 @@ def _get_password(self) -> str:
The password from the peer relation or None if the
password has not yet been set by the leader.
"""
return self._get_secret("app", USER_PASSWORD_KEY)
return self.get_secret("app", USER_PASSWORD_KEY)

@property
def _replication_password(self) -> str:
Expand All @@ -719,7 +751,7 @@ def _replication_password(self) -> str:
The password from the peer relation or None if the
password has not yet been set by the leader.
"""
return self._get_secret("app", REPLICATION_PASSWORD_KEY)
return self.get_secret("app", REPLICATION_PASSWORD_KEY)

def _install_apt_packages(self, _, packages: List[str]) -> None:
"""Simple wrapper around 'apt-get install -y.
Expand Down Expand Up @@ -775,6 +807,43 @@ def _peers(self) -> Relation:
"""
return self.model.get_relation(PEER)

def push_tls_files_to_workload(self) -> None:
"""Move TLS files to the PostgreSQL storage path and enable TLS."""
key, ca, cert = self.tls.get_tls_files()
if key is not None:
self._patroni.render_file(f"{self._storage_path}/{TLS_KEY_FILE}", key, 0o600)
if ca is not None:
self._patroni.render_file(f"{self._storage_path}/{TLS_CA_FILE}", ca, 0o600)
if cert is not None:
self._patroni.render_file(f"{self._storage_path}/{TLS_CERT_FILE}", cert, 0o600)

self.update_config()

def _restart(self, _) -> None:
"""Restart PostgreSQL."""
try:
self._patroni.restart_postgresql()
except RetryError as e:
logger.exception("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()


if __name__ == "__main__":
main(PostgresqlOperatorCharm)
48 changes: 23 additions & 25 deletions src/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,21 +84,18 @@ def __init__(
self.superuser_password = superuser_password
self.replication_password = replication_password

def bootstrap_cluster(self, replica: bool = False) -> bool:
def bootstrap_cluster(self) -> bool:
"""Bootstrap a PostgreSQL cluster using Patroni."""
# Render the configuration files and start the cluster.
self.configure_patroni_on_unit(replica)
self.configure_patroni_on_unit()
return self.start_patroni()

def configure_patroni_on_unit(self, replica: bool = False):
"""Configure Patroni (configuration files and service) on the unit.
Args:
replica: whether the unit should be configured as a replica
(defaults to False, which configures the unit as a leader)
"""
def configure_patroni_on_unit(self):
"""Configure Patroni (configuration files and service) on the unit."""
self._change_owner(self.storage_path)
self.render_patroni_yml_file(replica)
# Avoid rendering the Patroni config file if it was already rendered.
if not os.path.exists(f"{self.storage_path}/patroni.yml"):
self.render_patroni_yml_file()
self._render_patroni_service_file()
# Reload systemd services before trying to start Patroni.
daemon_reload()
Expand Down Expand Up @@ -221,7 +218,7 @@ def member_started(self) -> bool:

return r.json()["state"] == "running"

def _render_file(self, path: str, content: str, mode: int) -> None:
def render_file(self, path: str, content: str, mode: int) -> None:
"""Write a content rendered from a template to a file.
Args:
Expand All @@ -246,27 +243,31 @@ def _render_patroni_service_file(self) -> None:
template = Template(file.read())
# Render the template file with the correct values.
rendered = template.render(conf_path=self.storage_path)
self._render_file("/etc/systemd/system/patroni.service", rendered, 0o644)
self.render_file("/etc/systemd/system/patroni.service", rendered, 0o644)

def render_patroni_yml_file(self, replica: bool = False) -> 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 patroni.yml 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(
conf_path=self.storage_path,
enable_tls=enable_tls,
member_name=self.member_name,
peers_ips=self.peers_ips,
scope=self.cluster_name,
self_ip=self.unit_ip,
replica=replica,
superuser=USER,
superuser_password=self.superuser_password,
replication_password=self.replication_password,
version=self._get_postgresql_version(),
)
self._render_file(f"{self.storage_path}/patroni.yml", rendered, 0o644)
self.render_file(f"{self.storage_path}/patroni.yml", rendered, 0o644)

def render_postgresql_conf_file(self) -> None:
"""Render the PostgreSQL configuration file."""
Expand All @@ -281,7 +282,7 @@ def render_postgresql_conf_file(self) -> None:
synchronous_standby_names="*",
)
self._create_directory(f"{self.storage_path}/conf.d", mode=0o644)
self._render_file(f"{self.storage_path}/conf.d/postgresql-operator.conf", rendered, 0o644)
self.render_file(f"{self.storage_path}/conf.d/postgresql-operator.conf", rendered, 0o644)

def start_patroni(self) -> bool:
"""Start Patroni service using systemd.
Expand Down Expand Up @@ -317,14 +318,6 @@ def primary_changed(self, old_primary: str) -> bool:
primary = self.get_primary()
return primary != old_primary

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()

if service_running(PATRONI_SERVICE):
self.reload_patroni_configuration()

def remove_raft_member(self, member_ip: str) -> None:
"""Remove a member from the raft cluster.
Expand Down Expand Up @@ -356,3 +349,8 @@ def remove_raft_member(self, member_ip: str) -> None:
def reload_patroni_configuration(self):
"""Reload Patroni configuration after it was changed."""
requests.post(f"http://{self.unit_ip}: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.unit_ip}:8008/restart")
3 changes: 3 additions & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
ALL_CLIENT_RELATIONS = [DATABASE, LEGACY_DB, LEGACY_DB_ADMIN]
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"
# List of system usernames needed for correct work of the charm/workload.
Expand Down
Loading

0 comments on commit 3a1dc76

Please sign in to comment.