Skip to content

Commit 1762f3a

Browse files
committed
[charmed_mongos] feat: Add Charmed Mongos plugin
Signed-off-by: Neha Oudin <neha@oudin.red>
1 parent fcc6a5f commit 1762f3a

1 file changed

Lines changed: 387 additions & 0 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
# This file is part of the sos project: https://github.com/sosreport/sos
2+
#
3+
# This copyrighted material is made available to anyone wishing to use,
4+
# modify, copy, or redistribute it subject to the terms and conditions of
5+
# version 2 of the GNU General Public License.
6+
#
7+
# See the LICENSE file in the source distribution for further information.#
8+
9+
from enum import Enum
10+
import shutil
11+
from urllib.parse import quote, quote_plus, urlencode
12+
import os
13+
from pathlib import Path
14+
from typing import Dict, Optional, Tuple
15+
16+
import yaml
17+
18+
from sos.report.plugins import Plugin, PluginOpt, UbuntuPlugin
19+
from sos.utilities import is_executable
20+
21+
DATE_FORMAT = "%Y-%m-%d-%H"
22+
23+
24+
class Substrate(Enum):
25+
VM = "vm"
26+
K8S = "k8s"
27+
28+
29+
class CharmedMongos(Plugin, UbuntuPlugin):
30+
"""The Charmed Mongos plugin is used to collect Mongos configuration
31+
and logs from the Charmed Mongos snap package or K8s deployment.
32+
33+
If all_logs is set to True, it collects all logs by default.
34+
The parameters `dbuser` and `dbpass` are used to dump database information,
35+
replicaset status, shard status, etc. You can provide those parameters with
36+
the environment variables `MONGOS_USER` and `MONGOS_PASSWORD`
37+
"""
38+
39+
short_desc = "Charmed Mongos"
40+
plugin_name = "charmed_mongos"
41+
42+
# Triggers
43+
packages = ("charmed-mongodb",)
44+
containers = ("mongos",)
45+
46+
snap_package = "charmed-mongodb"
47+
snap_path_common = "/var/snap/charmed-mongodb/common"
48+
snap_path_current = "/var/snap/charmed-mongodb/current"
49+
50+
kube_cmd = "kubectl"
51+
selector = "app.kubernetes.io/name=mongos-k8s"
52+
53+
conf_paths = {
54+
"MONGODB_CONF": "/etc/mongod",
55+
"MONGODB_LOGS": "/var/log/mongodb",
56+
}
57+
58+
option_list = [
59+
PluginOpt(
60+
name="dumpdbs",
61+
default=False,
62+
val_type=bool,
63+
desc="Set to true to dump server information.",
64+
),
65+
PluginOpt(
66+
"dbuser",
67+
default="",
68+
val_type=str,
69+
desc="Username for database dump collection",
70+
),
71+
PluginOpt(
72+
"dbpass",
73+
default="",
74+
val_type=str,
75+
desc="Password for database dump collection",
76+
),
77+
]
78+
79+
mongos_commands: Tuple[Tuple[str, str], ...] = (
80+
("EJSON.stringify(db.serverStatus())", "server_status.txt"),
81+
("EJSON.stringify(db.getUsers())", "db_users.txt"),
82+
("EJSON.stringify(db.getRoles())", "db_roles.txt"),
83+
(
84+
"EJSON.stringify(db.adminCommand({listDatabases: 1}))",
85+
"db_databases.txt",
86+
),
87+
("EJSON.stringify(sh.status())", "shard_cluster_status.txt"),
88+
("EJSON.stringify(sh.listShards())", "shard_shards.txt"),
89+
)
90+
91+
def _join_conf_path(self, base: str, *parts: str):
92+
stripped_parts = [p.lstrip(os.path.sep) for p in parts]
93+
return self.path_join(base, *stripped_parts)
94+
95+
def _get_db_credentials(self) -> Tuple[Optional[str], Optional[str]]:
96+
db_user = self.get_option("dbuser")
97+
db_pass = self.get_option("dbpass")
98+
99+
if not db_user:
100+
if "MONGOS_USER" in os.environ:
101+
self.soslog.info(
102+
"MONGOS_USER present: Using MONGOS_USER environment"
103+
"variable, user did not provide username."
104+
)
105+
db_user = os.environ["MONGOS_USER"]
106+
else:
107+
self.soslog.warning("error: Missing credentials (username)")
108+
return None, None
109+
110+
if not db_pass:
111+
if "MONGOS_PWD" in os.environ:
112+
self.soslog.info(
113+
"MONGOS_PWD present: Using MONGOS_PWD environment "
114+
"variable, user did not provide password."
115+
)
116+
db_pass = os.environ["MONGOS_PWD"]
117+
else:
118+
self.soslog.warning("error: Missing credentials (password)")
119+
return None, None
120+
121+
return db_user, db_pass
122+
123+
def vm_config_db(self) -> Optional[str]:
124+
_conf_file = f"{self.conf_paths['MONGODB_CONF']}/mongos.conf"
125+
conf_path = self._join_conf_path(self.snap_path_current, _conf_file)
126+
try:
127+
with open(conf_path, encoding="utf-8") as f:
128+
data = yaml.safe_load(f)
129+
130+
return data.get("sharding", {}).get("configDB", None)
131+
except FileNotFoundError:
132+
return None
133+
134+
def k8s_config_db(
135+
self, kube_cmd: str, cont: str, pod: str
136+
) -> Optional[str]:
137+
# Cat the configuration file.
138+
cat_conf_cmd = (
139+
f"{kube_cmd} exec -c {cont} {pod} -- "
140+
f"cat {self.conf_paths['MONGODB_CONF']}/mongos.conf"
141+
)
142+
result = self.exec_cmd(cat_conf_cmd)
143+
144+
if result.get("status") != 0:
145+
return None
146+
147+
data = yaml.safe_load(result.get("output", ""))
148+
return data.get("sharding", {}).get("configDB", None)
149+
150+
def _process_snap(self):
151+
config_db = self.vm_config_db()
152+
153+
if not config_db:
154+
# The service is not properly set up by the charm, exiting.
155+
return
156+
157+
base_conf = self._join_conf_path(
158+
self.snap_path_current, self.conf_paths["MONGODB_CONF"]
159+
)
160+
base_logs = self._join_conf_path(
161+
self.snap_path_common, self.conf_paths["MONGODB_LOGS"]
162+
)
163+
164+
all_logs = self.get_option("all_logs")
165+
166+
# Hide certificates and cluster keyfile.
167+
self.add_forbidden_path(
168+
[
169+
f"{base_conf}/*.pem",
170+
f"{base_conf}/*.crt",
171+
f"{base_conf}/keyFile",
172+
f"{base_conf}/mongod.conf",
173+
]
174+
)
175+
self.add_copy_spec([base_conf])
176+
177+
if all_logs:
178+
self.add_copy_spec([f"{base_logs}/*"])
179+
else:
180+
self.add_copy_spec([f"{base_logs}/*.log"])
181+
182+
lines = None if all_logs else 500
183+
self.add_journal("snap.charmed-mongodb.mongos", lines=lines)
184+
185+
self.add_cmd_output("snap info charmed-mongodb")
186+
187+
if not self.get_option("dumpdbs"):
188+
return
189+
190+
db_user, db_pass = self._get_db_credentials()
191+
if not db_user or not db_pass:
192+
return
193+
194+
mongos_uri = self._build_uri(db_user, Substrate.VM)
195+
mongodb_cmd = "charmed-mongodb.mongosh"
196+
env = {"MONGOS_PWD": db_pass}
197+
198+
# --- REGULAR INFORMATION ---
199+
for command, suggest_filename in self.mongos_commands:
200+
self.add_cmd_output(
201+
(
202+
f"sh -c '{mongodb_cmd} {mongos_uri} "
203+
f"--quiet --eval \"{command}\"'"
204+
),
205+
suggest_filename=suggest_filename,
206+
env=env,
207+
)
208+
209+
def _build_uri(
210+
self,
211+
db_user: str,
212+
substrate: Substrate,
213+
) -> str:
214+
base_conf = self.conf_paths["MONGODB_CONF"]
215+
args: Dict[str, str] = {"authSource": "admin"}
216+
217+
if substrate == Substrate.VM:
218+
base_conf = self._join_conf_path(self.snap_path_current, base_conf)
219+
220+
external_ca = Path(f"{base_conf}/external-ca.crt")
221+
external_cert = Path(f"{base_conf}/external-cert.pem")
222+
if external_ca.exists() and external_cert.exists():
223+
args |= {
224+
"tls": "true",
225+
"tlsCertificateKeyFile": f"{external_cert}",
226+
"tlsCaFile": f"{external_ca}",
227+
}
228+
_args = urlencode(args)
229+
user = quote_plus(db_user)
230+
host = "127.0.0.1:27018"
231+
socket_path = Path(f"{self.snap_path_current}/var/mongodb-27018.sock")
232+
if substrate == Substrate.VM and socket_path.exists():
233+
host = quote(f"{socket_path}", safe="")
234+
return f"mongodb://{user}:${{MONGOS_PWD}}@{host}/admin?{_args}"
235+
236+
def _determine_namespaces(self) -> list[str]:
237+
namespaces = self.exec_cmd(
238+
f"{self.kube_cmd} get pods -A -l {self.selector} "
239+
"-o jsonpath='{.items[*].metadata.namespace}'"
240+
)
241+
if namespaces["status"] == 0:
242+
return list(set(namespaces["output"].strip().split()))
243+
return []
244+
245+
def _get_pod_names(self, namespace) -> list[str]:
246+
pods = self.exec_cmd(
247+
f"{self.kube_cmd} -n {namespace} get pods -l {self.selector} "
248+
"-o jsonpath='{.items[*].metadata.name}'"
249+
)
250+
if pods["status"] == 0:
251+
return pods["output"].strip().split()
252+
return []
253+
254+
def _remote_exec(
255+
self,
256+
kube_cmd: str,
257+
cont: str,
258+
pod: str,
259+
mongod_cmd: str,
260+
uri: str,
261+
password: str,
262+
cmd: str,
263+
cmd_name: str,
264+
):
265+
output_file = f"/tmp/eval_{cmd_name}" # nosec: B108
266+
# We first execute the command and write into a file
267+
query_cmd = (
268+
f"{kube_cmd} exec -c {cont} {pod} -- "
269+
f'sh -lc \'export MONGOS_PWD="{password}"; {mongod_cmd} {uri} '
270+
f'--quiet --eval "{cmd}" > {output_file}\''
271+
)
272+
273+
self.exec_cmd(query_cmd)
274+
275+
# We then cat that file to have the command output.
276+
cat_cmd = (
277+
f"{kube_cmd} exec -c {cont} {pod} -- "
278+
f"sh -lc 'cat {output_file} && rm {output_file}'"
279+
)
280+
281+
self.add_cmd_output(
282+
cmds=cat_cmd,
283+
suggest_filename=f"{pod}_{cmd_name}",
284+
)
285+
286+
def _collect_per_namespace(self, ns: str, all_logs: bool):
287+
kube_cmd = f"{self.kube_cmd} -n {ns}"
288+
289+
mongodb_cont = "mongod"
290+
pods = self._get_pod_names(ns)
291+
logs_path = self.conf_paths["MONGODB_LOGS"]
292+
conf_path = self.conf_paths["MONGODB_CONF"]
293+
294+
# Get the config and logs from each pod
295+
dump_files_path = self.get_cmd_output_path()
296+
for path in self.conf_paths.values():
297+
for pod in pods:
298+
name_prefix = f"{dump_files_path}/pods/{pod}/{path}"
299+
os.makedirs(name_prefix, exist_ok=True)
300+
copy_cmd = (
301+
f"{kube_cmd} cp -c {mongodb_cont} "
302+
f"{pod}:{path} {name_prefix}"
303+
)
304+
self.exec_cmd(copy_cmd)
305+
306+
for pod in pods:
307+
if all_logs: # This is all_logs
308+
self.add_copy_spec([f"{dump_files_path}/{pod}/{logs_path}/*"])
309+
else:
310+
self.add_copy_spec(
311+
[f"{dump_files_path}/{pod}/{logs_path}/*.log"]
312+
)
313+
314+
self.add_forbidden_path(
315+
[
316+
f"{dump_files_path}/pods/{pod}/{conf_path}/*.pem",
317+
f"{dump_files_path}/pods/{pod}/{conf_path}/*.crt",
318+
f"{dump_files_path}/pods/{pod}/{conf_path}/keyFile",
319+
f"{dump_files_path}/pods/{pod}/{conf_path}/mongod.conf",
320+
]
321+
)
322+
self.add_copy_spec([f"{dump_files_path}/pods/{pod}/{conf_path}"])
323+
324+
if not self.get_option("dumpdbs"):
325+
return
326+
327+
db_user, db_pass = self._get_db_credentials()
328+
if not db_user or not db_pass:
329+
return
330+
331+
mongos_uri = self._build_uri(db_user, Substrate.K8S)
332+
mongodb_cmd = "mongosh"
333+
334+
for pod in pods:
335+
config_db = self.k8s_config_db(kube_cmd, mongodb_cont, pod)
336+
if not config_db:
337+
continue
338+
for command, suggest_filename in self.mongos_commands:
339+
self._remote_exec(
340+
kube_cmd=kube_cmd,
341+
cont=mongodb_cont,
342+
pod=pod,
343+
mongod_cmd=mongodb_cmd,
344+
uri=mongos_uri,
345+
password=db_pass,
346+
cmd=command,
347+
cmd_name=suggest_filename,
348+
)
349+
350+
def _process_k8s(self):
351+
all_logs = self.get_option("all_logs") or False
352+
namespaces = self._determine_namespaces()
353+
for namespace in namespaces:
354+
self._collect_per_namespace(namespace, all_logs)
355+
356+
def setup(self) -> None:
357+
if self.is_installed(self.snap_package):
358+
self._process_snap()
359+
360+
if is_executable(self.kube_cmd, self.sysroot):
361+
self._process_k8s()
362+
363+
def postproc(self):
364+
if self.is_installed(self.snap_package):
365+
substrate = Substrate.VM
366+
if not self.vm_config_db():
367+
# Service was not properly set up.
368+
return
369+
else:
370+
substrate = Substrate.K8S
371+
372+
base_conf = self.conf_paths["MONGODB_CONF"]
373+
if substrate == Substrate.VM:
374+
base_conf = self._join_conf_path(self.snap_path_current, base_conf)
375+
else:
376+
base_conf = f"{self.get_cmd_output_path()}/pods/*/{base_conf}"
377+
shutil.rmtree(
378+
f"{self.get_cmd_output_path()}/pods",
379+
ignore_errors=True
380+
)
381+
382+
# --- SCRUB PASSWORDS ---
383+
self.do_path_regex_sub(
384+
f"{base_conf}/*",
385+
regexp=r'("queryPassword": ")[^"]*"',
386+
subst=r"\1*********",
387+
)

0 commit comments

Comments
 (0)