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

feat: Mutating webhook #352

Open
wants to merge 9 commits into
base: 6/edge
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,19 @@ containers:
mounts:
- storage: mongodb
location: /var/lib/mongodb
webhook-mutator:
resource: data-platform-k8s-webhook-mutator-image
resources:
mongodb-image:
type: oci-image
description: OCI image for mongodb
# TODO: Update sha whenever upstream rock changes
upstream-source: ghcr.io/canonical/charmed-mongodb@sha256:b4b3edb805b20de471da57802643bfadbf979f112d738bc540ab148d145ddcfe
data-platform-k8s-webhook-mutator-image:
type: oci-image
description: OCI image for mongodb
# TODO: Update sha whenever upstream rock changes
upstream-source: ghcr.io/canonical/data-platform-k8s-mutator@sha256:bd10e490771c9124b7daaecfb95cfae3a9f45a77af8c94de70556cfaaffd8a4a
storage:
mongodb:
type: filesystem
Expand Down
116 changes: 102 additions & 14 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
CrossAppVersionChecker,
get_charm_revision,
)
from lightkube import Client
from lightkube.resources.admissionregistration_v1 import MutatingWebhookConfiguration
from ops.charm import (
ActionEvent,
CharmBase,
Expand Down Expand Up @@ -89,6 +91,8 @@
MissingSecretError,
NotConfigServerError,
)
from gen_cert import gen_certificate
from service_manager import SERVICE_NAME, generate_mutating_webhook, generate_service
from upgrades import kubernetes_upgrades
from upgrades.mongodb_upgrades import MongoDBUpgrade

Expand All @@ -115,6 +119,10 @@ class MongoDBCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)

self.framework.observe(
self.on.webhook_mutator_pebble_ready,
self._on_webhook_mutator_pebble_ready,
)
self.framework.observe(self.on.mongod_pebble_ready, self._on_mongod_pebble_ready)
self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.start, self._on_start)
Expand Down Expand Up @@ -177,6 +185,11 @@ def __init__(self, *args):

# BEGIN: properties

@property
def _is_removing_last_replica(self) -> bool:
"""Returns True if the last replica (juju unit) is getting removed."""
return self.app.planned_units() == 0 and len(self.peers_units) == 0

@property
def monitoring_jobs(self) -> list[dict[str, Any]]:
"""Defines the labels and targets for metrics."""
Expand Down Expand Up @@ -369,6 +382,28 @@ def _backup_layer(self) -> Layer:
}
return Layer(layer_config)

@property
def _webhook_layer(self) -> Layer:
"""Returns a Pebble configuration layer for wehooks mutator."""
config = Config.WebhookManager
cmd = f"uvicorn app:app --host 0.0.0.0 --port {config.PORT} --ssl-keyfile={config.KEY_PATH} --ssl-certfile={config.CRT_PATH}"
layer_config = {
"summary": "Webhook Manager layer",
"description": "Pebble layer configuration for webhook mutation",
"services": {
Config.WebhookManager.SERVICE_NAME: {
"override": "merge",
"summary": "webhook manager daemon",
"command": cmd,
"startup": "enabled",
"environment": {
"GRACE_PERIOD_SECONDS": Config.WebhookManager.GRACE_PERIOD_SECONDS,
},
},
},
}
return Layer(layer_config)

@property
def relation(self) -> Optional[Relation]:
"""Peer relation data object."""
Expand Down Expand Up @@ -601,6 +636,54 @@ def _filesystem_handler(self, container: Container) -> None:
logger.error("Cannot initialize workload: %r", e)
raise FailedToUpdateFilesystem

# BEGIN: charm events
def _on_mongod_pebble_ready(self, event) -> None:
"""Configure MongoDB pebble layer specification."""
container = self.unit.get_container(Config.CONTAINER_NAME)

# Just run the configure layers steps on the container and defer if it fails.
try:
self._configure_container(container)
except ContainerNotReadyError:
event.defer()
return

self.upgrade._reconcile_upgrade(event)

# BEGIN: charm events
def _on_webhook_mutator_pebble_ready(self, event) -> None:
# still need todo use lightkube register the mutating webhook with
# lightkube (maybe in on start)?
# Get a reference the container attribute
container = self.unit.get_container(Config.WebhookManager.CONTAINER_NAME)
if not container.can_connect():
logger.debug("%s container is not ready yet.", Config.WebhookManager.CONTAINER_NAME)
event.defer()
return

cert = self.get_secret(APP_SCOPE, Config.WebhookManager.CRT_SECRET)
private_key = self.get_secret(APP_SCOPE, Config.WebhookManager.KEY_SECRET)

if not cert or not private_key:
logger.debug("Waiting for certificates")
event.defer()
return

container.push(Config.WebhookManager.CRT_PATH, cert)
container.push(Config.WebhookManager.KEY_PATH, private_key)

# Add initial Pebble config layer using the Pebble API
container.add_layer(Config.WebhookManager.SERVICE_NAME, self._webhook_layer, combine=True)
container.replan()

if not self.unit.is_leader():
return

# Lightkube client
client = Client()
generate_service(client, self.unit, self.model.name)
generate_mutating_webhook(client, self.unit, self.model.name, cert)

def _configure_layers(self, container: Container) -> None:
"""Configure the layers of the container."""
modified = False
Expand Down Expand Up @@ -683,19 +766,6 @@ def _on_upgrade(self, event: UpgradeCharmEvent) -> None:
# Post upgrade event verifies the success of the upgrade.
self.upgrade.post_app_upgrade_event.emit()

def _on_mongod_pebble_ready(self, event) -> None:
"""Configure MongoDB pebble layer specification."""
container = self.unit.get_container(Config.CONTAINER_NAME)

# Just run the configure layers steps on the container and defer if it fails.
try:
self._configure_container(container)
except ContainerNotReadyError:
event.defer()
return

self.upgrade._reconcile_upgrade(event)

def is_db_service_ready(self) -> bool:
"""Checks if the MongoDB service is ready to accept connections."""
with MongoDBConnection(self.mongodb_config, "localhost", direct=True) as direct_mongo:
Expand Down Expand Up @@ -925,6 +995,13 @@ def __handle_upgrade_on_stop(self) -> None:
return

def _on_stop(self, event) -> None:
if self._is_removing_last_replica:
client = Client()
client.delete(
MutatingWebhookConfiguration,
namespace=self.model.name,
name=SERVICE_NAME,
)
self.__handle_partition_on_stop()
if self.unit_departed:
self.__handle_relation_departed_on_stop()
Expand Down Expand Up @@ -1301,6 +1378,17 @@ def _check_or_set_keyfile(self) -> None:
if not self.get_secret(APP_SCOPE, "keyfile"):
self._generate_keyfile()

def _check_or_set_webhook_certs(self) -> None:
"""Set TLS certs for webhooks."""
if not self.unit.is_leader():
return
if not self.get_secret(APP_SCOPE, "webhook-certificate") or not self.get_secret(
APP_SCOPE, "webhook-key"
):
cert, key = gen_certificate(Config.WebhookManager.SERVICE_NAME, self.model.name)
self.set_secret(APP_SCOPE, "webhook-certificate", cert.decode())
self.set_secret(APP_SCOPE, "webhook-key", key.decode())

def _generate_keyfile(self) -> None:
self.set_secret(APP_SCOPE, "keyfile", generate_keyfile())

Expand All @@ -1327,8 +1415,8 @@ def _generate_secrets(self) -> None:
"""
self._check_or_set_user_password(OperatorUser)
self._check_or_set_user_password(MonitorUser)

self._check_or_set_keyfile()
self._check_or_set_webhook_certs()

def _initialise_replica_set(self, event: StartEvent) -> None:
"""Initialise replica set and create users."""
Expand Down
12 changes: 12 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ class Status:
)
WAITING_POST_UPGRADE_STATUS = WaitingStatus("Waiting for post upgrade checks")

class WebhookManager:
"""Webhook Manager related constants."""

CONTAINER_NAME = "webhook-mutator"
SERVICE_NAME = "fastapi"
GRACE_PERIOD_SECONDS = 31_556_952 # one year
PORT = 8000
CRT_PATH = "/app/certificate.crt"
KEY_PATH = "/app/certificate.key"
CRT_SECRET = "webhook-certificate"
KEY_SECRET = "webhook-key"

@staticmethod
def get_license_path(license_name: str) -> str:
"""Return the path to the license file."""
Expand Down
52 changes: 52 additions & 0 deletions src/gen_cert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Generates a self signed certificate for the mutating webhook."""
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.
import datetime

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID


def gen_certificate(app_name: str, ns: str) -> tuple[bytes, bytes]:
"""Generates a tuple of cert and key for the mutating webhook."""
one_day = datetime.timedelta(1, 0, 0)
private_key = rsa.generate_private_key(
public_exponent=65537, key_size=2048, backend=default_backend()
)
public_key = private_key.public_key()

builder = x509.CertificateBuilder()
builder = builder.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, app_name)]))
builder = builder.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, ns)]))
builder = builder.not_valid_before(datetime.datetime.today() - one_day)
builder = builder.not_valid_after(datetime.datetime.today() + (one_day * 365 * 100))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice :)

builder = builder.serial_number(x509.random_serial_number())
builder = builder.public_key(public_key)
builder = builder.add_extension(
x509.SubjectAlternativeName(
[
x509.DNSName(f"{app_name}.{ns}.svc"),
]
),
critical=False,
)
builder = builder.add_extension(
x509.BasicConstraints(ca=False, path_length=None), critical=True
)

certificate = builder.sign(
private_key=private_key, algorithm=hashes.SHA256(), backend=default_backend()
)

return (
certificate.public_bytes(serialization.Encoding.PEM),
private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
),
)
Loading
Loading