Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TLS implementation #30

Merged
merged 44 commits into from
Sep 19, 2022
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
4a0399e
Change charm database user
marceloneppel Aug 16, 2022
37c0639
Fix unit tests
marceloneppel Aug 16, 2022
d05d991
Fix integration test call
marceloneppel Aug 16, 2022
52226c7
Merge branch 'main' into new-user
marceloneppel Aug 16, 2022
13c5aa9
Merge branch 'main' into new-user
marceloneppel Aug 17, 2022
aa61f60
Fix user name in library
marceloneppel Aug 17, 2022
991de5f
Fix user
marceloneppel Aug 17, 2022
db18ba6
Add default postgres user creation
marceloneppel Aug 25, 2022
61946da
Change action name
marceloneppel Aug 25, 2022
5f01c6e
Rework secrets management
marceloneppel Aug 25, 2022
aa14718
Add password rotation logic
marceloneppel Aug 26, 2022
9e7fdfd
Add user to the action parameters
marceloneppel Aug 26, 2022
12a4a7b
Add separate environments for different tests
marceloneppel Aug 27, 2022
bc723d1
Add all dependencies
marceloneppel Aug 27, 2022
3e22732
Merge branch 'password-rotation' into parallelize-tests
marceloneppel Aug 27, 2022
2d90e03
Add pytest marks
marceloneppel Aug 27, 2022
3fab9c6
Merge branch 'password-rotation' into parallelize-tests
marceloneppel Aug 27, 2022
82a9a68
Merge branch 'main' into password-rotation
marceloneppel Sep 1, 2022
3f259d5
Fix action description
marceloneppel Sep 2, 2022
56ff42a
Fix method docstring
marceloneppel Sep 2, 2022
62cc604
Fix pytest mark
marceloneppel Sep 2, 2022
63004b4
Fix pytest markers
marceloneppel Sep 2, 2022
e188259
Merge branch 'password-rotation' into parallelize-tests
marceloneppel Sep 2, 2022
42a5b13
Register pytest markers
marceloneppel Sep 3, 2022
79d885b
Merge branch 'main' into parallelize-tests
marceloneppel Sep 5, 2022
3b04265
Import files
marceloneppel Sep 6, 2022
1560781
Import files
marceloneppel Sep 6, 2022
ee43a39
Update libraries
marceloneppel Sep 7, 2022
7764cb0
Merge branch 'tls-relations' into tls-implementation
marceloneppel Sep 7, 2022
7c1c74e
Add TLS implementation
marceloneppel Sep 7, 2022
0bce570
Delete file
marceloneppel Sep 7, 2022
9d761b7
Update library
marceloneppel Sep 8, 2022
00bd1e8
Fix PostgreSQL library
marceloneppel Sep 8, 2022
f25f7b0
Merge branch 'tls-relations' into tls-implementation
marceloneppel Sep 8, 2022
79b1447
Merge branch 'main' into tls-relations
marceloneppel Sep 9, 2022
35a83dd
Merge branch 'tls-relations' into tls-implementation
marceloneppel Sep 9, 2022
91a1f26
Add jsonschema as a binary dependency
marceloneppel Sep 12, 2022
c472047
Merge branch 'tls-relations' into tls-implementation
marceloneppel Sep 12, 2022
154df25
Change hostname to unit ip
marceloneppel Sep 12, 2022
24ac60e
Add unit test dependency
marceloneppel Sep 12, 2022
acee685
Merge branch 'main' into tls-implementation
marceloneppel Sep 14, 2022
d886188
Call certificate update on config change
marceloneppel Sep 14, 2022
8cb09db
Fix docstring
marceloneppel Sep 14, 2022
c4875f6
Change log call
marceloneppel Sep 14, 2022
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
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:
"""Uploads TLS files to the workload container."""
WRFitch marked this conversation as resolved.
Show resolved Hide resolved
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.error("failed to restart PostgreSQL")
WRFitch marked this conversation as resolved.
Show resolved Hide resolved
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