# Copyright (c) 2023 Canonical Ltd., Chi Wai Chan <chiwai.chan@canonical.com>
# This file is part of the sos project: https://github.com/sosreport/sos
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# version 2 of the GNU General Public License.
#
# See the LICENSE file in the source distribution for further information.
import logging
import json
import re
from sos.collector.clusters import Cluster
from sos.utilities import sos_parse_version
from sos.utilities import sos_get_command_output
def _parse_option_string(strings=None):
"""Parse comma separated string."""
if not strings:
return []
return [string.strip() for string in strings.split(",")]
def _get_index(model_name):
"""Helper function to get Index.
The reason why we need Index defined in function is because currently
the collector.__init__ will load all the classes in this module
and also Index. This will cause bug because it think Index is
Cluster type. Also We don't want to provide a customized
filter to remove Index class.
"""
class Index:
"""Index structure to help parse juju status output.
Attributes apps, units and machines are dict which key
is the app/unit/machine name
and the value is list of targets which format are
{model_name}:{machine_id}.
"""
def __init__(self, model_name):
self.model_name: str = model_name
self.apps = {}
self.units = {}
self.machines = {}
self.ui_log = logging.getLogger("sos")
def add_principals(self, juju_status):
"""Adds principal units to index."""
for app, app_info in juju_status["applications"].items():
nodes = []
units = app_info.get("units", {})
for unit, unit_info in units.items():
machine = unit_info["machine"]
node = f"{self.model_name}:{machine}"
self.units[unit] = [node]
self.machines[machine] = [node]
nodes.append(node)
self.apps[app] = nodes
def add_subordinates(self, juju_status):
"""Add subordinates to index.
Since subordinates does not have units they need to be
manually added.
"""
for app, app_info in juju_status["applications"].items():
subordinate_to = app_info.get("subordinate-to", [])
for parent in subordinate_to:
# If parent is missing
if not self.apps.get(parent):
self.ui_log.warning(
f"Principal charm {parent} is missing"
)
continue
self.apps[app].extend(self.apps[parent])
# If parent's units is missing
if "units" not in juju_status["applications"][parent]:
self.ui_log.warning(
f"Principal charm {parent} is missing units"
)
continue
units = juju_status["applications"][parent]["units"]
for unit, unit_info in units.items():
node = f"{self.model_name}:{unit_info['machine']}"
for sub_key, sub_value in unit_info.get(
"subordinates", {}
).items():
if sub_key.startswith(app + "/"):
self.units[sub_key] = [node]
def add_machines(self, juju_status):
"""Add machines to index.
If model does not have any applications it needs to be
manually added.
"""
for machine in juju_status["machines"].keys():
node = f"{self.model_name}:{machine}"
self.machines[machine] = [node]
return Index(model_name)
class juju(Cluster):
"""
The juju cluster profile is intended to be used on juju managed clouds.
It"s assumed that `juju` is installed on the machine where `sos` is called,
and that the juju user has superuser privilege to the current controller.
By default, the sos reports will be collected from all the applications in
the current model. If necessary, you can filter the nodes by models /
applications / units / machines with cluster options.
Example:
sos collect --cluster-type juju -c "juju.models=sos" -c "juju.apps=a,b,c"
"""
cmd = "juju"
cluster_name = "Juju Managed Clouds"
option_list = [
("apps", "", "Filter node list by apps (comma separated regex)."),
("units", "", "Filter node list by units (comma separated string)."),
("models", "", "Filter node list by models (comma separated string)."),
(
"machines",
"",
"Filter node list by machines (comma separated string).",
),
]
def _cleanup_juju_output(self, output):
"""Remove leading characters before {."""
return re.sub(r"(^[^{]*)(.*)", "\\2", output, 0, re.MULTILINE)
def _get_model_info(self, model_name):
"""Parse juju status output and return target dict.
Here are couple helper functions to parse the juju principals units,
subordinate units and machines.
"""
juju_status = self._execute_juju_status(model_name)
index = _get_index(model_name=model_name)
index.add_principals(juju_status)
index.add_subordinates(juju_status)
index.add_machines(juju_status)
return index
def _get_juju_version(self):
"""Grab the version of juju"""
res = sos_get_command_output("juju version")
return res['output']
def _execute_juju_status(self, model_name):
model_option = f"-m {model_name}" if model_name else ""
format_option = "--format json"
juju_version = self._get_juju_version()
if sos_parse_version(juju_version) > sos_parse_version("3"):
format_option += " --no-color"
status_cmd = f"{self.cmd} status {model_option} {format_option}"
res = self.exec_primary_cmd(status_cmd)
if not res["status"] == 0:
raise Exception(f"'{status_cmd}' returned error: {res['status']}")
juju_json_output = self._cleanup_juju_output((res["output"]))
juju_status = None
try:
juju_status = json.loads(juju_json_output)
except json.JSONDecodeError:
raise Exception(
"Juju output is not valid json format."
f"Output: {juju_json_output}"
)
return juju_status
def _filter_by_pattern(self, key, patterns, model_info):
"""Filter with regex match."""
nodes = set()
for pattern in patterns:
for param, value in getattr(model_info, key).items():
if re.match(pattern, param):
nodes.update(value or [])
return nodes
def _filter_by_fixed(self, key, patterns, model_info):
"""Filter with fixed match."""
nodes = set()
for pattern in patterns:
for param, value in getattr(model_info, key).items():
if pattern == param:
nodes.update(value or [])
return nodes
def set_transport_type(self):
"""Dynamically change transport to 'juju'."""
return "juju"
def get_nodes(self):
"""Get the machine numbers from `juju status`."""
models = _parse_option_string(self.get_option("models"))
apps = _parse_option_string(self.get_option("apps"))
units = _parse_option_string(self.get_option("units"))
machines = _parse_option_string(self.get_option("machines"))
filters = {"apps": apps, "units": units, "machines": machines}
# Return empty nodes if no model and filter provided.
if not any(filters.values()) and not models:
return []
if not models:
models = [""] # use current model by default
nodes = set()
for model in models:
model_info = self._get_model_info(model)
for key, resource in filters.items():
# Filter node by different policies
if key == "apps":
_nodes = self._filter_by_pattern(key, resource, model_info)
else:
_nodes = self._filter_by_fixed(key, resource, model_info)
nodes.update(_nodes)
return list(nodes)
# vim: set et ts=4 sw=4 :