[sunbeam-clusterd] implement tls certificates interface

Optionally allow clusterd to be integrated with tls certificates
interface. When integrated, get a certificates managed by the leader
replacing the cluster certificates auto-generated by microcluster.

Change-Id: Ia019bd533962976ddc68e2b93bcdcbe28a5cff9c
Signed-off-by: Guillaume Boutry <guillaume.boutry@canonical.com>
This commit is contained in:
Guillaume Boutry 2024-06-27 15:52:07 +02:00
parent ed4ed712bb
commit c81a45e5f9
No known key found for this signature in database
GPG Key ID: E95E3326872E55DE
5 changed files with 217 additions and 15 deletions

View File

@ -52,3 +52,8 @@ config:
debug: debug:
default: False default: False
type: boolean type: boolean
requires:
certificates:
interface: tls-certificates
optional: True

View File

@ -21,7 +21,9 @@ This charm manages a clusterd deployment. Clusterd is a service storing
every metadata about a sunbeam deployment. every metadata about a sunbeam deployment.
""" """
import hashlib
import logging import logging
import socket
from pathlib import ( from pathlib import (
Path, Path,
) )
@ -30,11 +32,18 @@ import clusterd
import ops.framework import ops.framework
import ops_sunbeam.charm as sunbeam_charm import ops_sunbeam.charm as sunbeam_charm
import ops_sunbeam.guard as sunbeam_guard import ops_sunbeam.guard as sunbeam_guard
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
import requests import requests
import tenacity import tenacity
from charms.operator_libs_linux.v2 import ( from charms.operator_libs_linux.v2 import (
snap, snap,
) )
from charms.tls_certificates_interface.v3.tls_certificates import (
generate_csr,
)
from cryptography import (
x509,
)
from ops.main import ( from ops.main import (
main, main,
) )
@ -55,6 +64,87 @@ def _identity(x: bool) -> bool:
return x return x
class ClusterCertificatesHandler(sunbeam_rhandlers.TlsCertificatesHandler):
"""Handler for certificates interface."""
def get_entity(self) -> ops.Unit | ops.Application:
"""Return the entity for the key store."""
return self.charm.model.app
def key_names(self) -> list[str]:
"""Return the key names managed by this relation.
First key is considered as default key.
"""
return ["main", "client"]
def csrs(self) -> dict[str, bytes]:
"""Return a dict of generated csrs for self.key_names().
The method calling this method will ensure that all keys have a matching
csr.
"""
main_key = self._private_keys.get("main")
client_key = self._private_keys.get("client")
if not main_key or not client_key:
return {}
return {
"main": generate_csr(
private_key=main_key.encode(),
subject=self.charm.app.name,
sans_ip=self.sans_ips,
sans_dns=self.sans_dns,
additional_critical_extensions=[
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=True,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
x509.ExtendedKeyUsage({x509.OID_SERVER_AUTH}),
],
),
"client": generate_csr(
private_key=client_key.encode(),
subject=self.charm.app.name + "-client",
additional_critical_extensions=[
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=True,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
x509.ExtendedKeyUsage({x509.OID_CLIENT_AUTH}),
],
),
}
def get_client_keypair(self) -> dict[str, str]:
"""Return client keypair with the CA."""
client_key = self.store.get_private_key("client")
client_csr = self.store.get_csr("client")
if client_key is None or client_csr is None:
return {}
for cert in self.get_certs():
if cert.csr == client_csr:
return {
"certificate-authority": cert.ca,
"certificate": cert.certificate,
"private-key-secret": client_key,
}
return {}
class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm): class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
"""Charm the service.""" """Charm the service."""
@ -65,7 +155,9 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
def __init__(self, framework: ops.Framework) -> None: def __init__(self, framework: ops.Framework) -> None:
"""Run constructor.""" """Run constructor."""
super().__init__(framework) super().__init__(framework)
self._state.set_default(channel="config", departed=False) self._state.set_default(
channel="config", departed=False, certs_hash=""
)
self.framework.observe(self.on.install, self._on_install) self.framework.observe(self.on.install, self._on_install)
self.framework.observe(self.on.stop, self._on_stop) self.framework.observe(self.on.stop, self._on_stop)
self.framework.observe( self.framework.observe(
@ -88,8 +180,26 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
"peers" in self.mandatory_relations, "peers" in self.mandatory_relations,
) )
handlers.append(self.peers) handlers.append(self.peers)
if self.can_add_handler("certificates", handlers):
self.certs = ClusterCertificatesHandler(
self,
"certificates",
self.configure_charm,
self.get_domain_name_sans(),
self.get_sans_ips(),
mandatory="certificates" in self.mandatory_relations,
)
handlers.append(self.certs)
return super().get_relation_handlers(handlers) return super().get_relation_handlers(handlers)
def get_domain_name_sans(self) -> list[str]:
"""Return domain name sans."""
return [socket.gethostname()]
def get_sans_ips(self) -> list[str]:
"""Return Subject Alternate Names to use in cert for service."""
return ["127.0.0.1"]
def _on_install(self, event: ops.InstallEvent) -> None: def _on_install(self, event: ops.InstallEvent) -> None:
"""Handle install event.""" """Handle install event."""
try: try:
@ -115,13 +225,23 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
"""Handle get-credentials action.""" """Handle get-credentials action."""
if not self.peers.interface.state.joined: if not self.peers.interface.state.joined:
event.fail("Clusterd not joined yet") event.fail("Clusterd not joined yet")
return
credentials = {}
if relation := self.model.get_relation(self.certs.relation_name):
if relation.active:
credentials = self.certs.get_client_keypair()
if not credentials:
event.fail("No credentials found yet")
return
event.set_results( event.set_results(
{ {
"url": "https://" "url": "https://"
+ self._binding_address() + self._binding_address()
+ ":" + ":"
+ str(self.clusterd_port) + str(self.clusterd_port),
**credentials,
} }
) )
@ -174,10 +294,11 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
if not self.clusterd_ready(): if not self.clusterd_ready():
logger.debug("Clusterd not ready yet.") logger.debug("Clusterd not ready yet.")
event.defer() event.defer()
return raise sunbeam_guard.WaitingExceptionError("Clusterd not ready yet")
if not self.is_leader_ready(): if not self.is_leader_ready():
self.bootstrap_cluster() self.bootstrap_cluster()
self.peers.interface.state.joined = True self.peers.interface.state.joined = True
self.configure_certificates()
super().configure_app_leader(event) super().configure_app_leader(event)
if isinstance(event, ClusterdNewNodeEvent): if isinstance(event, ClusterdNewNodeEvent):
self.add_node_to_cluster(event) self.add_node_to_cluster(event)
@ -200,6 +321,26 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
} }
self.set_snap_data(snap_data) self.set_snap_data(snap_data)
def configure_certificates(self):
"""Configure certificates."""
if not self.unit.is_leader():
logger.debug("Not leader, skipping certificate configuration.")
return
if not self.certs.ready:
logger.debug("Certificates not ready yet.")
return
certs = self.certs.context()
certs_hash = hashlib.sha256(bytes(str(certs), "utf-8")).hexdigest()
if certs_hash == self._state.certs_hash:
logger.debug("Certificates have not changed.")
return
self._clusterd.set_certs(
ca=certs["ca_cert_main"],
key=certs["key_main"],
cert=certs["cert_main"],
)
self._state.certs_hash = certs_hash
def set_snap_data(self, snap_data: dict): def set_snap_data(self, snap_data: dict):
"""Set snap data on local snap.""" """Set snap data on local snap."""
cache = snap.SnapCache() cache = snap.SnapCache()
@ -242,7 +383,6 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
self.unit.name.replace("/", "-"), self.unit.name.replace("/", "-"),
self._binding_address() + ":" + str(self.clusterd_port), self._binding_address() + ":" + str(self.clusterd_port),
) )
self.status.set(ops.ActiveStatus())
def add_node_to_cluster(self, event: ClusterdNewNodeEvent) -> None: def add_node_to_cluster(self, event: ClusterdNewNodeEvent) -> None:
"""Generate token for node joining.""" """Generate token for node joining."""
@ -277,7 +417,6 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
already_left = self._wait_for_roles_to_settle_before_removal( already_left = self._wait_for_roles_to_settle_before_removal(
event, self_departing event, self_departing
) )
self.status.set(ops.ActiveStatus())
if already_left: if already_left:
return return
@ -309,7 +448,6 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
raise sunbeam_guard.WaitingExceptionError( raise sunbeam_guard.WaitingExceptionError(
"Waiting for roles to settle" "Waiting for roles to settle"
) )
self.status.set(ops.ActiveStatus())
def _wait_for_roles_to_settle_before_removal( def _wait_for_roles_to_settle_before_removal(
self, event: ops.EventBase, self_departing: bool self, event: ops.EventBase, self_departing: bool
@ -399,7 +537,6 @@ class SunbeamClusterdCharm(sunbeam_charm.OSBaseOperatorCharm):
logger.debug("Member %s is still pending", member) logger.debug("Member %s is still pending", member)
event.defer() event.defer()
return return
self.status.set(ops.ActiveStatus())
def _wait_until_role_set(self, name: str) -> bool: def _wait_until_role_set(self, name: str) -> bool:
@tenacity.retry( @tenacity.retry(

View File

@ -82,6 +82,9 @@ class ClusterdClient:
def _post(self, path, data=None, json=None, **kwargs): def _post(self, path, data=None, json=None, **kwargs):
return self._request("post", path, data=data, json=json, **kwargs) return self._request("post", path, data=data, json=json, **kwargs)
def _put(self, path, data=None, json=None, **kwargs):
return self._request("put", path, data=data, json=json, **kwargs)
def _delete(self, path, **kwargs): def _delete(self, path, **kwargs):
return self._request("delete", path, **kwargs) return self._request("delete", path, **kwargs)
@ -177,3 +180,15 @@ class ClusterdClient:
data = {"name": name} data = {"name": name}
result = self._post("/cluster/1.0/tokens", data=json.dumps(data)) result = self._post("/cluster/1.0/tokens", data=json.dumps(data))
return str(result["metadata"]) return str(result["metadata"])
def set_certs(self, ca: str, cert: str, key: str):
"""Configure cluster certificates.
The CA is not set in the cluster certificates, but in the config endpoint.
This is because we don't want microcluster to go full CA-mode.
"""
self._put("/1.0/config/cluster-ca", data=ca)
data = {"public_key": cert, "private_key": key}
self._put(
"/cluster/internal/cluster/certificates", data=json.dumps(data)
)

View File

@ -165,6 +165,7 @@ EXTERNAL_OPENSTACK_IMAGES_SYNC_LIBS=(
EXTERNAL_SUNBEAM_CLUSTERD_LIBS=( EXTERNAL_SUNBEAM_CLUSTERD_LIBS=(
"operator_libs_linux" "operator_libs_linux"
"tls_certificates_interface"
) )
EXTERNAL_SUNBEAM_MACHINE_LIBS=( EXTERNAL_SUNBEAM_MACHINE_LIBS=(

View File

@ -13,17 +13,34 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import base64
import contextlib
import json import json
import logging import logging
import subprocess import subprocess
import tempfile
import unittest import unittest
from random import shuffle from random import shuffle
from typing import Tuple from typing import Tuple
import requests import requests
import requests.adapters
import tenacity import tenacity
import zaza
import zaza.model as model import zaza.model as model
import zaza.openstack.charm_tests.test_utils as test_utils import zaza.openstack.charm_tests.test_utils as test_utils
from juju.client import client
from juju.model import Model
@contextlib.contextmanager
def keypair(certificate: bytes, private_key: bytes):
with tempfile.NamedTemporaryFile() as cert_file, tempfile.NamedTemporaryFile() as key_file:
cert_file.write(certificate)
cert_file.flush()
key_file.write(private_key)
key_file.flush()
yield (cert_file.name, key_file.name)
class ClusterdTest(test_utils.BaseCharmTest): class ClusterdTest(test_utils.BaseCharmTest):
@ -69,16 +86,46 @@ class ClusterdTest(test_utils.BaseCharmTest):
for unit in units: for unit in units:
model.block_until_unit_wl_status(unit, "active", timeout=60 * 5) model.block_until_unit_wl_status(unit, "active", timeout=60 * 5)
async def _read_secret(
self, model: Model, secret_id: str
) -> dict[str, str]:
facade = client.SecretsFacade.from_connection(model.connection())
secrets = await facade.ListSecrets(
filter_={"uri": secret_id}, show_secrets=True
)
if len(secrets.results) != 1:
self.fail("Secret not found")
return secrets["results"][0].value.data
def test_100_connect_to_clusterd(self): def test_100_connect_to_clusterd(self):
"""Try sending data to an endpoint.""" """Try sending data to an endpoint."""
action = model.run_action_on_leader( action = model.run_action_on_leader(
self.application_name, "get-credentials" self.application_name, "get-credentials"
) )
url = action.data["results"]["url"] + "/1.0/config/100_connect" url = action.data["results"]["url"] + "/1.0/config/100_connect"
response = requests.put(url, json={"data": "test"}, verify=False) private_key_secret = action.data["results"].get("private-key-secret")
response.raise_for_status() certificate = action.data["results"].get("certificate")
response = requests.get(url, verify=False) if private_key_secret is None or certificate is None:
response.raise_for_status() context = contextlib.nullcontext()
logging.debug("Request made without mTLS")
else:
model_impl = zaza.sync_wrapper(model.get_model)()
private_key = base64.b64decode(
zaza.sync_wrapper(self._read_secret)(
model_impl, private_key_secret
)["private-key"]
)
context = keypair(certificate.encode(), private_key)
logging.debug("Request made with mTLS")
with context as cert:
response = requests.put(
url, json={"data": "test"}, verify=False, cert=cert
)
response.raise_for_status()
response = requests.get(url, verify=False, cert=cert)
response.raise_for_status()
self.assertEqual( self.assertEqual(
json.loads(response.json()["metadata"])["data"], "test" json.loads(response.json()["metadata"])["data"], "test"
) )
@ -106,14 +153,11 @@ class ClusterdTest(test_utils.BaseCharmTest):
"""Scale back to 3.""" """Scale back to 3."""
self._add_2_units() self._add_2_units()
@unittest.skip("Skip until scale down stable") @unittest.skip("Skip until scale down stable")
def test_203_scale_down_to_2_units(self): def test_203_scale_down_to_2_units(self):
"""Scale down to 2 units for voter/spare test.""" """Scale down to 2 units for voter/spare test."""
leader = model.get_lead_unit_name(self.application_name) leader = model.get_lead_unit_name(self.application_name)
model.destroy_unit( model.destroy_unit(self.application_name, leader, wait_disappear=True)
self.application_name, leader, wait_disappear=True
)
model.block_until_all_units_idle() model.block_until_all_units_idle()
units = self._get_units() units = self._get_units()