Skip to content

Commit afa37ca

Browse files
authored
patch(DPE-9750,6): allowConnectionsWithoutCertificates (#289)
1 parent be39f49 commit afa37ca

16 files changed

Lines changed: 243 additions & 71 deletions

File tree

single_kernel_mongo/events/tls.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None:
175175
event.certificate,
176176
event.ca,
177177
)
178-
self.dependent.state.update_ca_secrets(event.ca)
179178

180179
# If we don't have both certificates, we early return, the next
181180
# certificate available event will enable certificates for this

single_kernel_mongo/managers/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ def tls_parameters(self) -> dict[str, Any]:
368368
"certificateKeyFile": f"{self.workload.paths.ext_pem_file}",
369369
"mode": "preferTLS",
370370
"disabledProtocols": "TLS1_0,TLS1_1",
371+
"allowConnectionsWithoutCertificates": True,
371372
}
372373
},
373374
}

single_kernel_mongo/managers/tls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def disable_certificates_for_unit(self):
204204
self.state.tls.set_secret(internal, SECRET_CHAIN_LABEL, None)
205205

206206
self.state.update_ca_secrets(new_ca=None)
207+
self.state.update_client_ca_secrets(new_ca=None)
207208

208209
self.delete_certificates_from_workload()
209210
self.dependent.restart_charm_services(force=True)
@@ -275,6 +276,11 @@ def set_certificates(
275276
if not self.certificate_and_private_key_match(certificate, internal):
276277
raise UnknownCertificateAvailableError
277278

279+
if internal:
280+
self.dependent.state.update_ca_secrets(ca)
281+
else:
282+
self.dependent.state.update_client_ca_secrets(ca)
283+
278284
self.state.tls.set_secret(
279285
internal,
280286
SECRET_CHAIN_LABEL,

single_kernel_mongo/state/charm_state.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,9 +692,8 @@ def update_ca_secrets(self, new_ca: str | None) -> None:
692692
self.config_server_data_interface.update_relation_data(
693693
relation.id, {AppShardingComponentKeys.INT_CA_SECRET.value: new_ca}
694694
)
695-
self._update_client_ca_secrets(new_ca)
696695

697-
def _update_client_ca_secrets(self, new_ca: str | None) -> None:
696+
def update_client_ca_secrets(self, new_ca: str | None) -> None:
698697
"""Updates the CA secret for the right values on the right fields."""
699698
if not self.charm.unit.is_leader():
700699
return

tests/integration/applications/continuous_write_charm/config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,11 @@ options:
66
type: string
77
description: |
88
URI provided to connect to MongoDB cluster via mongos
9+
tls-ca:
10+
type: string
11+
description: |
12+
TLS CA certificate to use alongside mongos-uri
13+
database-name:
14+
type: string
15+
description: |
16+
Database name to request.

tests/integration/applications/continuous_write_charm/metadata.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ summary: |
99
only for testing high availability of the MongoDB charm.
1010
1111
requires:
12-
database:
12+
mongodb:
1313
interface: mongodb_client
1414
limit: 1
1515

16+
provides:
17+
mongos:
18+
interface: mongos_client
19+
1620
peers:
1721
application-peers:
1822
interface: application-peers

tests/integration/applications/continuous_write_charm/src/charm.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
import signal
1414
import subprocess
1515
import sys
16+
from pathlib import Path
17+
from urllib.parse import quote_plus, urlencode
1618

17-
from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires
19+
from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires, DatabaseCreatedEvent
1820
from ops.charm import ActionEvent, CharmBase
1921
from ops.main import main
2022
from ops.model import ActiveStatus, Relation, WaitingStatus
2123
from pymongo import MongoClient
24+
from pymongo.uri_parser import parse_uri
2225
from tenacity import RetryError, Retrying, stop_after_delay, wait_fixed
2326

2427
logger = logging.getLogger(__name__)
@@ -30,6 +33,8 @@
3033
PROC_PID_KEY = "proc-pid"
3134
LAST_WRITTEN_FILE = "last_written_value"
3235

36+
CA_PATH = Path("/tmp/ca.crt")
37+
3338

3439
class ContinuousWritesApplication(CharmBase):
3540
"""Application charm that continuously writes to MongoDB."""
@@ -51,13 +56,35 @@ def __init__(self, *args):
5156
)
5257

5358
# Database related events
54-
self.database = DatabaseRequires(self, "database", DATABASE_NAME)
59+
self.database = DatabaseRequires(self, "mongodb", self.database_name)
60+
# Database related events
61+
self.mongos_database = DatabaseRequires(self, "mongos", self.database_name, external_node_connectivity=True)
62+
5563
self.framework.observe(self.database.on.database_created, self._on_database_created)
64+
self.framework.observe(self.mongos_database.on.database_created, self._on_database_created)
65+
66+
if (data:= list(self.database.fetch_relation_data().values())):
67+
if (tls_ca := data[0].get("tls-ca")):
68+
CA_PATH.write_text(tls_ca)
69+
return
70+
71+
if (data:= list(self.mongos_database.fetch_relation_data().values())):
72+
if (tls_ca := data[0].get("tls-ca")):
73+
CA_PATH.write_text(tls_ca)
74+
return
75+
76+
if tls_ca := self.model.config.get("tls-ca", None):
77+
CA_PATH.write_text(tls_ca)
78+
return
5679

5780
# ==============
5881
# Properties
5982
# ==============
6083

84+
@property
85+
def database_name(self) -> str:
86+
return self.model.config.get("database-name", DATABASE_NAME)
87+
6188
@property
6289
def _peers(self) -> Relation | None:
6390
"""Retrieve the peer relation (`ops.model.Relation`)."""
@@ -76,33 +103,60 @@ def _database_config(self) -> dict[str, str]:
76103
"""Returns the database config to use to connect to the MongoDB cluster."""
77104
# In some tests we want to write directly to mongos, but the config-server does not
78105
# support integrations to client applications, so the data to connect is set via config.
79-
if not (data := list(self.database.fetch_relation_data().values())):
80-
return {"uris": self.model.config.get("mongos-uri", None)}
106+
if not self.database.relations and not self.mongos_database.relations:
107+
uri = self.model.config.get("mongos-uri", "")
108+
if self.model.config.get("tls-ca"):
109+
uri = self._build_tls_uri(uri)
110+
111+
return {"uris": uri}
112+
113+
if self.database.relations:
114+
data =list(self.database.fetch_relation_data().values())[0]
115+
elif self.mongos_database.relations:
116+
data =list(self.mongos_database.fetch_relation_data().values())[0]
117+
else:
118+
return {}
81119

82-
data = data[0]
83-
username, password, endpoints, replset, uris = (
120+
username, password, endpoints, replset, uris, tls = (
84121
data.get("username"),
85122
data.get("password"),
86123
data.get("endpoints"),
87124
data.get("replset"),
88125
data.get("uris"),
126+
data.get("tls")
89127
)
90128

91-
if None in [username, password, endpoints, replset, uris]:
129+
if None in [username, password, endpoints, uris]:
92130
return {}
93131

132+
if tls:
133+
uris = self._build_tls_uri(uris)
134+
94135
return {
95136
"user": username,
96137
"password": password,
97138
"endpoints": endpoints,
98-
"replset": replset,
139+
"replset": replset or "",
99140
"uris": uris,
100141
}
101142

102143
# ==============
103144
# Helpers
104145
# ==============
105146

147+
def _build_tls_uri(self, uris: str) -> str:
148+
parsed_uri = parse_uri(uris)
149+
params = parsed_uri["options"]
150+
params["tls"] = "true"
151+
params["tlsCaFile"] = f"{CA_PATH}"
152+
hosts = ",".join(f"{host}:{port}" for host, port in parsed_uri["nodelist"])
153+
return (
154+
f"mongodb://{quote_plus(parsed_uri['username'])}:"
155+
f"{quote_plus(parsed_uri['password'])}@"
156+
f"{hosts}/{quote_plus(parsed_uri['database'])}?"
157+
f"{urlencode(params)}"
158+
)
159+
106160
def _start_continuous_writes(
107161
self, starting_number: int, db_name: str, collection_name: str
108162
) -> None:
@@ -148,8 +202,9 @@ def _stop_continuous_writes(self, db_name: str, collection_name: str) -> int | N
148202
logger.info(
149203
f"Process {self.proc_id_key(db_name, collection_name)} was killed already (or never existed)"
150204
)
151-
152-
del self.app_peer_data[self.proc_id_key(db_name, collection_name)]
205+
return -1
206+
finally:
207+
del self.app_peer_data[self.proc_id_key(db_name, collection_name)]
153208

154209
# read the last written_value
155210
try:
@@ -170,7 +225,7 @@ def proc_id_key(self, db_name: str, collection_name: str) -> str:
170225
return f"{PROC_PID_KEY}-{db_name}-{collection_name}"
171226

172227
def last_written_filename(self, db_name: str, collection_name: str) -> str:
173-
"""Returns a process id key for the continuous writes process to a given db and coll."""
228+
"""Returns the filename for the written data for a given db and coll."""
174229
return f"{LAST_WRITTEN_FILE}-{db_name}-{collection_name}"
175230

176231
# ==============
@@ -210,7 +265,7 @@ def _on_start_continuous_writes_action(self, event) -> None:
210265
if not self._database_config:
211266
return
212267

213-
db_name = event.params.get("db-name") or DATABASE_NAME
268+
db_name = event.params.get("db-name") or self.database_name
214269
collection_name = event.params.get("collection-name") or COLLECTION_NAME
215270
self._start_continuous_writes(1, db_name, collection_name)
216271

@@ -225,10 +280,12 @@ def _on_stop_continuous_writes_action(self, event: ActionEvent) -> None:
225280
event.set_results({"writes": writes or -1})
226281
return None
227282

228-
def _on_database_created(self, _) -> None:
283+
def _on_database_created(self, event: DatabaseCreatedEvent) -> None:
229284
"""Handle the database created event."""
230-
self.unit.status = ActiveStatus()
285+
if event.tls == "True":
286+
CA_PATH.write_text(event.tls_ca)
231287

288+
self.unit.status = ActiveStatus()
232289

233290
if __name__ == "__main__":
234291
main(ContinuousWritesApplication)

tests/integration/helpers/common.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ class ProcessError(Exception):
8888
"""Raised when a process fails."""
8989

9090

91+
class SecretNotFoundError(Exception):
92+
"""Raised when a secret is not found."""
93+
94+
9195
async def deploy_charm(
9296
ops_test: OpsTest,
9397
charm: str,
@@ -127,6 +131,7 @@ async def deploy_application(
127131
ops_test: OpsTest,
128132
application_path: str,
129133
app_name: str,
134+
database_name: str = DEFAULT_DATABASE_NAME,
130135
):
131136
"""Deploys the helpers applications with one unit and waits for idle."""
132137
application_name = await get_app_name(ops_test, app_name)
@@ -137,6 +142,7 @@ async def deploy_application(
137142
application_name=app_name,
138143
num_units=1,
139144
series="jammy",
145+
config={"database-name": database_name},
140146
)
141147
# TODO: remove raise_on_error when we move to juju 3.5 (DPE-4996)
142148
await ops_test.model.wait_for_idle(
@@ -157,13 +163,13 @@ async def relate_mongodb_and_application(
157163
mongodb_application_name: The mongodb charm application name
158164
application_name: The continuous writes test charm application name
159165
"""
160-
if is_relation_joined(ops_test, "database", "database"):
166+
if is_relation_joined(ops_test, "mongodb", "database"):
161167
return
162168

163169
await ops_test.model.integrate(
164-
f"{application_name}:database", f"{mongodb_application_name}:database"
170+
f"{application_name}:mongodb", f"{mongodb_application_name}:database"
165171
)
166-
await ops_test.model.block_until(lambda: is_relation_joined(ops_test, "database", "database"))
172+
await ops_test.model.block_until(lambda: is_relation_joined(ops_test, "mongodb", "database"))
167173

168174
await ops_test.model.wait_for_idle(
169175
apps=[mongodb_application_name, application_name],
@@ -365,6 +371,24 @@ async def get_password(
365371
return None
366372

367373

374+
async def get_secret_by_label(ops_test: OpsTest, label: str) -> dict[str, str]:
375+
secrets_raw = await ops_test.juju("list-secrets")
376+
secret_ids = [
377+
secret_line.split()[0] for secret_line in secrets_raw[1].split("\n")[1:] if secret_line
378+
]
379+
380+
for secret_id in secret_ids:
381+
secret_data_raw = await ops_test.juju(
382+
"show-secret", "--format", "json", "--reveal", secret_id
383+
)
384+
secret_data = json.loads(secret_data_raw[1])
385+
386+
if label == secret_data[secret_id].get("label"):
387+
return secret_data[secret_id]["content"]["Data"]
388+
389+
raise SecretNotFoundError(f"Secret with label {label} not found.")
390+
391+
368392
@retry(
369393
retry=retry_if_result(lambda x: x == 0),
370394
stop=stop_after_attempt(5),
@@ -982,7 +1006,12 @@ async def clear_continous_writes(
9821006

9831007

9841008
async def count_writes(
985-
ops_test: OpsTest, substrate: Substrate, app_name: str, unit: JujuUnit
1009+
ops_test: OpsTest,
1010+
substrate: Substrate,
1011+
app_name: str,
1012+
unit: JujuUnit,
1013+
db_name: str = DEFAULT_DATABASE_NAME,
1014+
coll_name: str = DEFAULT_COLLECTION_NAME,
9861015
) -> int:
9871016
"""New versions of pymongo no longer support the count operation, instead find is used."""
9881017
host = await get_address_of_unit(ops_test, substrate, get_unit_id(unit.name), app_name=app_name)
@@ -991,8 +1020,8 @@ async def count_writes(
9911020
)
9921021

9931022
client = MongoClient(uri, directConnection=True)
994-
db = client[DEFAULT_DATABASE_NAME]
995-
test_collection = db[DEFAULT_COLLECTION_NAME]
1023+
db = client[db_name]
1024+
test_collection = db[coll_name]
9961025
count = test_collection.count_documents({})
9971026
client.close()
9981027
return count

0 commit comments

Comments
 (0)