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

Move private key from peer data to juju secrets #66

Merged
merged 10 commits into from
Jan 5, 2024
Merged
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
400 changes: 400 additions & 0 deletions lib/charms/observability_libs/v1/cert_handler.py

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@

import functools
import logging
import shutil
from collections import defaultdict
from datetime import datetime
from pathlib import Path

import pytest
from pytest_operator.plugin import OpsTest

CERTHANDLER_PATH = "lib/charms/observability_libs/v1/cert_handler.py"
TESTINGCHARM_PATH = "tests/integration/tester-charm"

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -55,3 +61,34 @@ async def o11y_libs_charm(ops_test):
"""The charm used for integration testing."""
charm = await ops_test.build_charm(".")
return charm


@pytest.fixture(scope="module")
@timed_memoizer
async def tester_charm(ops_test: OpsTest) -> Path:
"""A tester charm to integration test the CertHandler lib."""
# Clean libs
shutil.rmtree(f"{TESTINGCHARM_PATH}/lib", ignore_errors=True)

# Link to lib
dest_charmlib = Path(f"{TESTINGCHARM_PATH}/{CERTHANDLER_PATH}")
dest_charmlib.parent.mkdir(parents=True)
dest_charmlib.hardlink_to(CERTHANDLER_PATH)

# fetch tls_certificates lib
fetch_tls_cmd = [
"charmcraft",
"fetch-lib",
"charms.tls_certificates_interface.v2.tls_certificates",
]
await ops_test.run(*fetch_tls_cmd)
shutil.move("lib/charms/tls_certificates_interface", f"{TESTINGCHARM_PATH}/lib/charms/")

# build the charm
clean_cmd = ["charmcraft", "clean", "-p", TESTINGCHARM_PATH]
await ops_test.run(*clean_cmd)
charm = await ops_test.build_charm(TESTINGCHARM_PATH)

# clean libs
shutil.rmtree(f"{TESTINGCHARM_PATH}/lib")
return charm
67 changes: 67 additions & 0 deletions tests/integration/test_cert_handler_v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

import asyncio
import logging
import subprocess
from pathlib import Path

import pytest
import yaml
from pytest_operator.plugin import OpsTest

logger = logging.getLogger(__name__)

METADATA = yaml.safe_load(Path("./tests/integration/tester-charm/metadata.yaml").read_text())
APP_NAME = METADATA["name"]


@pytest.mark.abort_on_fail
async def test_cert_handler_v1(
ops_test: OpsTest,
tester_charm: Path,
):
"""Validate the integration between TesterCharm and self-signed-certificates using CertHandler v1."""
ca_app_name = "ca"
apps = [APP_NAME, ca_app_name]

image = METADATA["resources"]["httpbin-image"]["upstream-source"]
resources = {"httpbin-image": image}

await asyncio.gather(
ops_test.model.deploy(
"self-signed-certificates",
application_name=ca_app_name,
channel="beta",
trust=True,
),
ops_test.model.deploy(
tester_charm,
resources=resources,
application_name=APP_NAME,
),
)
logger.info("All services deployed")

# wait for all charms to be active
await ops_test.model.wait_for_idle(apps=apps, status="active", wait_for_exact_units=1)
logger.info("All services active")

await ops_test.model.add_relation(APP_NAME, ca_app_name)
logger.info("Relations issued")
await ops_test.model.wait_for_idle(apps=apps, status="active", wait_for_exact_units=1)

# Check the certs files are in the filesystem
for path in ["/tmp/server.key", "/tmp/server.cert", "/tmp/ca.cert"]:
assert 0 == subprocess.check_call(
[
"juju",
"ssh",
"--model",
ops_test.model_full_name,
"--container",
"httpbin",
f"{APP_NAME}/0",
f"ls {path}",
]
)
9 changes: 9 additions & 0 deletions tests/integration/tester-charm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
venv/
build/
*.charm
.tox/
.coverage
__pycache__/
*.py[cod]
.idea
.vscode/
31 changes: 31 additions & 0 deletions tests/integration/tester-charm/charmcraft.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# This file configures Charmcraft.
# See https://juju.is/docs/sdk/charmcraft-config for guidance.

type: charm

bases:
- build-on:
- name: ubuntu
channel: "22.04"
run-on:
- name: ubuntu
channel: "22.04"

parts:
charm:
build-packages:
- git
charm-binary-python-packages:
- jsonschema
- cryptography

config:
options:
# An example config option to customise the log level of the workload
log-level:
description: |
Configures the log level of gunicorn.

Acceptable values are: "info", "debug", "warning", "error" and "critical"
default: "info"
type: string
31 changes: 31 additions & 0 deletions tests/integration/tester-charm/metadata.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: tester-charm
assumes:
- k8s-api

# Juju 3.0.3+ needed for secrets and open-port
- juju >= 3.0.3

summary: Tester charm
description: Tester charm

requires:
certificates:
interface: tls-certificates
limit: 1
description: |
Obtain a CA and a server certificate for Prometheus to use for TLS.
The same CA cert is used for all in-cluster requests, e.g.:
- (client) scraping targets for self-monitoring
- (client) posting alerts to alertmanager server
- (server) serving data to grafana

containers:
httpbin:
resource: httpbin-image

resources:
httpbin-image:
type: oci-image
description: OCI image for httpbin
upstream-source: kennethreitz/httpbin

3 changes: 3 additions & 0 deletions tests/integration/tester-charm/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
cryptography
jsonschema
ops
140 changes: 140 additions & 0 deletions tests/integration/tester-charm/src/charm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
# Copyright 2024 Canonical
# See LICENSE file for licensing details.

"""Tester Charm."""

import logging

import ops
from charms.observability_libs.v1.cert_handler import CertHandler

# Log messages can be retrieved using juju debug-log
logger = logging.getLogger(__name__)

VALID_LOG_LEVELS = ["info", "debug", "warning", "error", "critical"]

KEY_PATH = "/tmp/server.key"
CERT_PATH = "/tmp/server.cert"
CA_CERT_PATH = "/tmp/ca.cert"


class TesterCharm(ops.CharmBase):
"""Tester Charm."""

def __init__(self, *args):
super().__init__(*args)
self._name = "httpbin"
self._container = self.unit.get_container(self._name)
self.cert_handler = CertHandler(
charm=self,
key="tester-server-cert",
sans=["charm.tester"],
)
self.framework.observe(self.cert_handler.on.cert_changed, self._on_server_cert_changed)
self.framework.observe(self.on["httpbin"].pebble_ready, self._on_httpbin_pebble_ready)
self.framework.observe(self.on.config_changed, self._on_config_changed)

def _on_server_cert_changed(self, _):
self._update_cert()

def _on_httpbin_pebble_ready(self, event: ops.PebbleReadyEvent):
"""Define and start a workload using the Pebble API.

Change this example to suit your needs. You'll need to specify the right entrypoint and
environment configuration for your specific workload.

Learn more about interacting with Pebble at at https://juju.is/docs/sdk/pebble.
"""
# Get a reference the container attribute on the PebbleReadyEvent
container = event.workload
# Add initial Pebble config layer using the Pebble API
container.add_layer("httpbin", self._pebble_layer, combine=True)
# Make Pebble reevaluate its plan, ensuring any services are started if enabled.
container.replan()
# Learn more about statuses in the SDK docs:
# https://juju.is/docs/sdk/constructs#heading--statuses
self.unit.status = ops.ActiveStatus()

def _on_config_changed(self, event: ops.ConfigChangedEvent):
"""Handle changed configuration.

Change this example to suit your needs. If you don't need to handle config, you can remove
this method.

Learn more about config at https://juju.is/docs/sdk/config
"""
# Fetch the new config value
log_level = self.model.config["log-level"].lower()

# Do some validation of the configuration option
if log_level in VALID_LOG_LEVELS:
# Verify that we can connect to the Pebble API in the workload container
if self._container.can_connect():
# Push an updated layer with the new config
self._container.add_layer("httpbin", self._pebble_layer, combine=True)
self._container.replan()

logger.debug("Log level for gunicorn changed to '%s'", log_level)
self.unit.status = ops.ActiveStatus()
else:
# We were unable to connect to the Pebble API, so we defer this event
event.defer()
self.unit.status = ops.WaitingStatus("waiting for Pebble API")
else:
# In this case, the config option is bad, so block the charm and notify the operator.
self.unit.status = ops.BlockedStatus("invalid log level: '{log_level}'")

@property
def _pebble_layer(self) -> ops.pebble.LayerDict:
"""Return a dictionary representing a Pebble layer."""
return {
"summary": "httpbin layer",
"description": "pebble config layer for httpbin",
"services": {
"httpbin": {
"override": "replace",
"summary": "httpbin",
"command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent",
"startup": "enabled",
"environment": {
"GUNICORN_CMD_ARGS": f"--log-level {self.model.config['log-level']}"
},
}
},
}

def _is_cert_available(self) -> bool:
return (
self.cert_handler.enabled
and (self.cert_handler.server_cert is not None)
and (self.cert_handler.private_key is not None)
and (self.cert_handler.ca_cert is not None)
)

def _update_cert(self):
if not self._container.can_connect():
return

if self._is_cert_available():
# Save the workload certificates
self._container.push(
CERT_PATH,
self.cert_handler.server_cert, # pyright: ignore
make_dirs=True,
)
self._container.push(
KEY_PATH,
self.cert_handler.private_key, # pyright: ignore
make_dirs=True,
)
# Save the CA among the trusted CAs and trust it
self._container.push(
CA_CERT_PATH,
self.cert_handler.ca_cert, # pyright: ignore
make_dirs=True,
)


if __name__ == "__main__": # pragma: nocover
ops.main(TesterCharm) # type: ignore
46 changes: 46 additions & 0 deletions tests/scenario/test_cert_handler/test_cert_handler_v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import os
import socket
import sys
from pathlib import Path

import pytest
from ops import CharmBase
from scenario import Context, Relation, State

libs = str(Path(__file__).parent.parent.parent.parent / "lib")
sys.path.append(libs)

from lib.charms.observability_libs.v1.cert_handler import CertHandler # noqa E402


class MyCharm(CharmBase):
META = {
"name": "fabio",
"requires": {"certificates": {"interface": "certificates"}},
}

def __init__(self, fw):
super().__init__(fw)

# Set minimal Juju version
os.environ["JUJU_VERSION"] = "3.0.3"
self.ch = CertHandler(self, key="ch", sans=[socket.getfqdn()])


@pytest.fixture
def ctx():
return Context(MyCharm, MyCharm.META)


@pytest.fixture
def certificates():
return Relation("certificates")


@pytest.mark.parametrize("leader", (True, False))
def test_cert_joins(ctx, certificates, leader):
with ctx.manager(
certificates.joined_event, State(leader=leader, relations=[certificates])
) as runner:
runner.run()
assert runner.charm.ch.private_key
Loading