Skip to content

Commit 8318256

Browse files
Move private key from peer data to juju secrets (#66)
* add cert_handler.py v1 * move secrets data to unit relation data * follow Pietro's suggestions * fix scenario test * add new integration test * refactor .enabled * linting
1 parent 894a40c commit 8318256

File tree

10 files changed

+764
-0
lines changed

10 files changed

+764
-0
lines changed

lib/charms/observability_libs/v1/cert_handler.py

Lines changed: 400 additions & 0 deletions
Large diffs are not rendered by default.

tests/integration/conftest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@
44

55
import functools
66
import logging
7+
import shutil
78
from collections import defaultdict
89
from datetime import datetime
10+
from pathlib import Path
911

1012
import pytest
13+
from pytest_operator.plugin import OpsTest
14+
15+
CERTHANDLER_PATH = "lib/charms/observability_libs/v1/cert_handler.py"
16+
TESTINGCHARM_PATH = "tests/integration/tester-charm"
1117

1218
logger = logging.getLogger(__name__)
1319

@@ -55,3 +61,34 @@ async def o11y_libs_charm(ops_test):
5561
"""The charm used for integration testing."""
5662
charm = await ops_test.build_charm(".")
5763
return charm
64+
65+
66+
@pytest.fixture(scope="module")
67+
@timed_memoizer
68+
async def tester_charm(ops_test: OpsTest) -> Path:
69+
"""A tester charm to integration test the CertHandler lib."""
70+
# Clean libs
71+
shutil.rmtree(f"{TESTINGCHARM_PATH}/lib", ignore_errors=True)
72+
73+
# Link to lib
74+
dest_charmlib = Path(f"{TESTINGCHARM_PATH}/{CERTHANDLER_PATH}")
75+
dest_charmlib.parent.mkdir(parents=True)
76+
dest_charmlib.hardlink_to(CERTHANDLER_PATH)
77+
78+
# fetch tls_certificates lib
79+
fetch_tls_cmd = [
80+
"charmcraft",
81+
"fetch-lib",
82+
"charms.tls_certificates_interface.v2.tls_certificates",
83+
]
84+
await ops_test.run(*fetch_tls_cmd)
85+
shutil.move("lib/charms/tls_certificates_interface", f"{TESTINGCHARM_PATH}/lib/charms/")
86+
87+
# build the charm
88+
clean_cmd = ["charmcraft", "clean", "-p", TESTINGCHARM_PATH]
89+
await ops_test.run(*clean_cmd)
90+
charm = await ops_test.build_charm(TESTINGCHARM_PATH)
91+
92+
# clean libs
93+
shutil.rmtree(f"{TESTINGCHARM_PATH}/lib")
94+
return charm
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
import asyncio
5+
import logging
6+
import subprocess
7+
from pathlib import Path
8+
9+
import pytest
10+
import yaml
11+
from pytest_operator.plugin import OpsTest
12+
13+
logger = logging.getLogger(__name__)
14+
15+
METADATA = yaml.safe_load(Path("./tests/integration/tester-charm/metadata.yaml").read_text())
16+
APP_NAME = METADATA["name"]
17+
18+
19+
@pytest.mark.abort_on_fail
20+
async def test_cert_handler_v1(
21+
ops_test: OpsTest,
22+
tester_charm: Path,
23+
):
24+
"""Validate the integration between TesterCharm and self-signed-certificates using CertHandler v1."""
25+
ca_app_name = "ca"
26+
apps = [APP_NAME, ca_app_name]
27+
28+
image = METADATA["resources"]["httpbin-image"]["upstream-source"]
29+
resources = {"httpbin-image": image}
30+
31+
await asyncio.gather(
32+
ops_test.model.deploy(
33+
"self-signed-certificates",
34+
application_name=ca_app_name,
35+
channel="beta",
36+
trust=True,
37+
),
38+
ops_test.model.deploy(
39+
tester_charm,
40+
resources=resources,
41+
application_name=APP_NAME,
42+
),
43+
)
44+
logger.info("All services deployed")
45+
46+
# wait for all charms to be active
47+
await ops_test.model.wait_for_idle(apps=apps, status="active", wait_for_exact_units=1)
48+
logger.info("All services active")
49+
50+
await ops_test.model.add_relation(APP_NAME, ca_app_name)
51+
logger.info("Relations issued")
52+
await ops_test.model.wait_for_idle(apps=apps, status="active", wait_for_exact_units=1)
53+
54+
# Check the certs files are in the filesystem
55+
for path in ["/tmp/server.key", "/tmp/server.cert", "/tmp/ca.cert"]:
56+
assert 0 == subprocess.check_call(
57+
[
58+
"juju",
59+
"ssh",
60+
"--model",
61+
ops_test.model_full_name,
62+
"--container",
63+
"httpbin",
64+
f"{APP_NAME}/0",
65+
f"ls {path}",
66+
]
67+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
venv/
2+
build/
3+
*.charm
4+
.tox/
5+
.coverage
6+
__pycache__/
7+
*.py[cod]
8+
.idea
9+
.vscode/
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# This file configures Charmcraft.
2+
# See https://juju.is/docs/sdk/charmcraft-config for guidance.
3+
4+
type: charm
5+
6+
bases:
7+
- build-on:
8+
- name: ubuntu
9+
channel: "22.04"
10+
run-on:
11+
- name: ubuntu
12+
channel: "22.04"
13+
14+
parts:
15+
charm:
16+
build-packages:
17+
- git
18+
charm-binary-python-packages:
19+
- jsonschema
20+
- cryptography
21+
22+
config:
23+
options:
24+
# An example config option to customise the log level of the workload
25+
log-level:
26+
description: |
27+
Configures the log level of gunicorn.
28+
29+
Acceptable values are: "info", "debug", "warning", "error" and "critical"
30+
default: "info"
31+
type: string
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: tester-charm
2+
assumes:
3+
- k8s-api
4+
5+
# Juju 3.0.3+ needed for secrets and open-port
6+
- juju >= 3.0.3
7+
8+
summary: Tester charm
9+
description: Tester charm
10+
11+
requires:
12+
certificates:
13+
interface: tls-certificates
14+
limit: 1
15+
description: |
16+
Obtain a CA and a server certificate for Prometheus to use for TLS.
17+
The same CA cert is used for all in-cluster requests, e.g.:
18+
- (client) scraping targets for self-monitoring
19+
- (client) posting alerts to alertmanager server
20+
- (server) serving data to grafana
21+
22+
containers:
23+
httpbin:
24+
resource: httpbin-image
25+
26+
resources:
27+
httpbin-image:
28+
type: oci-image
29+
description: OCI image for httpbin
30+
upstream-source: kennethreitz/httpbin
31+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
cryptography
2+
jsonschema
3+
ops
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Canonical
3+
# See LICENSE file for licensing details.
4+
5+
"""Tester Charm."""
6+
7+
import logging
8+
9+
import ops
10+
from charms.observability_libs.v1.cert_handler import CertHandler
11+
12+
# Log messages can be retrieved using juju debug-log
13+
logger = logging.getLogger(__name__)
14+
15+
VALID_LOG_LEVELS = ["info", "debug", "warning", "error", "critical"]
16+
17+
KEY_PATH = "/tmp/server.key"
18+
CERT_PATH = "/tmp/server.cert"
19+
CA_CERT_PATH = "/tmp/ca.cert"
20+
21+
22+
class TesterCharm(ops.CharmBase):
23+
"""Tester Charm."""
24+
25+
def __init__(self, *args):
26+
super().__init__(*args)
27+
self._name = "httpbin"
28+
self._container = self.unit.get_container(self._name)
29+
self.cert_handler = CertHandler(
30+
charm=self,
31+
key="tester-server-cert",
32+
sans=["charm.tester"],
33+
)
34+
self.framework.observe(self.cert_handler.on.cert_changed, self._on_server_cert_changed)
35+
self.framework.observe(self.on["httpbin"].pebble_ready, self._on_httpbin_pebble_ready)
36+
self.framework.observe(self.on.config_changed, self._on_config_changed)
37+
38+
def _on_server_cert_changed(self, _):
39+
self._update_cert()
40+
41+
def _on_httpbin_pebble_ready(self, event: ops.PebbleReadyEvent):
42+
"""Define and start a workload using the Pebble API.
43+
44+
Change this example to suit your needs. You'll need to specify the right entrypoint and
45+
environment configuration for your specific workload.
46+
47+
Learn more about interacting with Pebble at at https://juju.is/docs/sdk/pebble.
48+
"""
49+
# Get a reference the container attribute on the PebbleReadyEvent
50+
container = event.workload
51+
# Add initial Pebble config layer using the Pebble API
52+
container.add_layer("httpbin", self._pebble_layer, combine=True)
53+
# Make Pebble reevaluate its plan, ensuring any services are started if enabled.
54+
container.replan()
55+
# Learn more about statuses in the SDK docs:
56+
# https://juju.is/docs/sdk/constructs#heading--statuses
57+
self.unit.status = ops.ActiveStatus()
58+
59+
def _on_config_changed(self, event: ops.ConfigChangedEvent):
60+
"""Handle changed configuration.
61+
62+
Change this example to suit your needs. If you don't need to handle config, you can remove
63+
this method.
64+
65+
Learn more about config at https://juju.is/docs/sdk/config
66+
"""
67+
# Fetch the new config value
68+
log_level = self.model.config["log-level"].lower()
69+
70+
# Do some validation of the configuration option
71+
if log_level in VALID_LOG_LEVELS:
72+
# Verify that we can connect to the Pebble API in the workload container
73+
if self._container.can_connect():
74+
# Push an updated layer with the new config
75+
self._container.add_layer("httpbin", self._pebble_layer, combine=True)
76+
self._container.replan()
77+
78+
logger.debug("Log level for gunicorn changed to '%s'", log_level)
79+
self.unit.status = ops.ActiveStatus()
80+
else:
81+
# We were unable to connect to the Pebble API, so we defer this event
82+
event.defer()
83+
self.unit.status = ops.WaitingStatus("waiting for Pebble API")
84+
else:
85+
# In this case, the config option is bad, so block the charm and notify the operator.
86+
self.unit.status = ops.BlockedStatus("invalid log level: '{log_level}'")
87+
88+
@property
89+
def _pebble_layer(self) -> ops.pebble.LayerDict:
90+
"""Return a dictionary representing a Pebble layer."""
91+
return {
92+
"summary": "httpbin layer",
93+
"description": "pebble config layer for httpbin",
94+
"services": {
95+
"httpbin": {
96+
"override": "replace",
97+
"summary": "httpbin",
98+
"command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent",
99+
"startup": "enabled",
100+
"environment": {
101+
"GUNICORN_CMD_ARGS": f"--log-level {self.model.config['log-level']}"
102+
},
103+
}
104+
},
105+
}
106+
107+
def _is_cert_available(self) -> bool:
108+
return (
109+
self.cert_handler.enabled
110+
and (self.cert_handler.server_cert is not None)
111+
and (self.cert_handler.private_key is not None)
112+
and (self.cert_handler.ca_cert is not None)
113+
)
114+
115+
def _update_cert(self):
116+
if not self._container.can_connect():
117+
return
118+
119+
if self._is_cert_available():
120+
# Save the workload certificates
121+
self._container.push(
122+
CERT_PATH,
123+
self.cert_handler.server_cert, # pyright: ignore
124+
make_dirs=True,
125+
)
126+
self._container.push(
127+
KEY_PATH,
128+
self.cert_handler.private_key, # pyright: ignore
129+
make_dirs=True,
130+
)
131+
# Save the CA among the trusted CAs and trust it
132+
self._container.push(
133+
CA_CERT_PATH,
134+
self.cert_handler.ca_cert, # pyright: ignore
135+
make_dirs=True,
136+
)
137+
138+
139+
if __name__ == "__main__": # pragma: nocover
140+
ops.main(TesterCharm) # type: ignore
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import os
2+
import socket
3+
import sys
4+
from pathlib import Path
5+
6+
import pytest
7+
from ops import CharmBase
8+
from scenario import Context, Relation, State
9+
10+
libs = str(Path(__file__).parent.parent.parent.parent / "lib")
11+
sys.path.append(libs)
12+
13+
from lib.charms.observability_libs.v1.cert_handler import CertHandler # noqa E402
14+
15+
16+
class MyCharm(CharmBase):
17+
META = {
18+
"name": "fabio",
19+
"requires": {"certificates": {"interface": "certificates"}},
20+
}
21+
22+
def __init__(self, fw):
23+
super().__init__(fw)
24+
25+
# Set minimal Juju version
26+
os.environ["JUJU_VERSION"] = "3.0.3"
27+
self.ch = CertHandler(self, key="ch", sans=[socket.getfqdn()])
28+
29+
30+
@pytest.fixture
31+
def ctx():
32+
return Context(MyCharm, MyCharm.META)
33+
34+
35+
@pytest.fixture
36+
def certificates():
37+
return Relation("certificates")
38+
39+
40+
@pytest.mark.parametrize("leader", (True, False))
41+
def test_cert_joins(ctx, certificates, leader):
42+
with ctx.manager(
43+
certificates.joined_event, State(leader=leader, relations=[certificates])
44+
) as runner:
45+
runner.run()
46+
assert runner.charm.ch.private_key

0 commit comments

Comments
 (0)