aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorjneo8 <james.lin@canonical.com>2023-03-31 19:53:32 +0800
committerJake Hunsaker <jhunsake@redhat.com>2023-04-28 11:25:38 -0400
commit5409ce346e487cad8800a3c072d443b39d1420a8 (patch)
treee59b2e62cd9e1b3101697b59bfb954de54e36b40
parenta1189b6e28545aac669ef925e6df3fae3caa2f25 (diff)
downloadsos-5409ce346e487cad8800a3c072d443b39d1420a8.tar.gz
[juju] Add juju integration to sos collect.
Add a new cluster profile and transport called "juju" for `sos collect`. Both the profile and transport are intended to be used on juju managed environments which assumes that `juju` is installed on the machine where `sos collect` is called, and that the juju user has superuser privilege to the current controller. When using the "juju" cluster profile, the sos reports will be collected from all the applications within the current model by default. If necessary, one can filter the nodes by models / applications / units / machines with cluster options. For example `-c "juju.models=sos" -c "juju.apps=a,b,c"`. Moreover, transport will also be dynamically changed to "juju" when cluster type is juju. If not using "juju" cluster profile, one can still choose to use the "juju" transport by specifying --transport option. However, not that the --nodes option will be expected to be a comma separated machine IDs , **not** IP addr, since `juju ssh` identifies the ssh target by machine ID. For example, `sos collect --nodes 0,1,2` Closes: #2668 Signed-off-by: Chi Wai Chan <chiwai.chan@canonical.com> Signed-off-by: jneo8 <james.lin@canonical.com> Co-authored-by: Chi Wai Chan <chiwai.chan@canonical.com> Co-authored-by: jneo8 <james.lin@canonical.com>
-rw-r--r--sos/collector/clusters/juju.py214
-rw-r--r--sos/collector/exceptions.py14
-rw-r--r--sos/collector/sosnode.py8
-rw-r--r--sos/collector/transports/__init__.py1
-rw-r--r--sos/collector/transports/juju.py84
-rw-r--r--tests/unittests/juju/__init__.py1
-rw-r--r--tests/unittests/juju/data/juju_output_sos.json1
-rw-r--r--tests/unittests/juju/data/juju_output_sos2.json1
-rw-r--r--tests/unittests/juju/juju_cluster_tests.py294
-rw-r--r--tests/unittests/juju/juju_transports_test.py85
10 files changed, 701 insertions, 2 deletions
diff --git a/sos/collector/clusters/juju.py b/sos/collector/clusters/juju.py
new file mode 100644
index 00000000..d783bd76
--- /dev/null
+++ b/sos/collector/clusters/juju.py
@@ -0,0 +1,214 @@
+# 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 json
+import re
+
+from sos.collector.clusters import Cluster
+
+
+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 = {}
+
+ 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:
+ self.apps[app].extend(self.apps[parent])
+ 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 _execute_juju_status(self, model_name):
+ model_option = f"-m {model_name}" if model_name else ""
+ format_option = "--format json"
+ 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 :
diff --git a/sos/collector/exceptions.py b/sos/collector/exceptions.py
index 5cfc8a02..e9edc249 100644
--- a/sos/collector/exceptions.py
+++ b/sos/collector/exceptions.py
@@ -112,6 +112,17 @@ class SaltStackMasterUnsupportedException(Exception):
super(SaltStackMasterUnsupportedException, self).__init__(message)
+class JujuNotInstalledException(Exception):
+ """Raised when juju is not installed locally"""
+
+ def __init__(self):
+ message = (
+ 'Juju is not installed, '
+ 'please ensure you have installed juju.'
+ )
+ super(JujuNotInstalledException, self).__init__(message)
+
+
__all__ = [
'AuthPermissionDeniedException',
'CommandTimeoutException',
@@ -124,5 +135,6 @@ __all__ = [
'SaltStackMasterUnsupportedException',
'TimeoutPasswordAuthException',
'UnsupportedHostException',
- 'InvalidTransportException'
+ 'InvalidTransportException',
+ 'JujuNotInstalledException'
]
diff --git a/sos/collector/sosnode.py b/sos/collector/sosnode.py
index f45df4f6..a167c263 100644
--- a/sos/collector/sosnode.py
+++ b/sos/collector/sosnode.py
@@ -17,6 +17,7 @@ import re
from pipes import quote
from sos.policies import load
from sos.policies.init_systems import InitSystem
+from sos.collector.transports.juju import JujuSSH
from sos.collector.transports.control_persist import SSHControlPersist
from sos.collector.transports.local import LocalTransport
from sos.collector.transports.oc import OCTransport
@@ -31,7 +32,8 @@ TRANSPORTS = {
'local': LocalTransport,
'control_persist': SSHControlPersist,
'oc': OCTransport,
- 'saltstack': SaltStackMaster
+ 'saltstack': SaltStackMaster,
+ 'juju': JujuSSH,
}
@@ -75,6 +77,10 @@ class SosNode():
self.soslog = logging.getLogger('sos')
self.ui_log = logging.getLogger('sos_ui')
self._transport = self._load_remote_transport(commons)
+ # Overwrite need_sudo if transports default_user
+ # is set and is not root.
+ if self._transport.default_user:
+ self.need_sudo = self._transport.default_user != 'root'
try:
self._transport.connect(self._password)
except Exception as err:
diff --git a/sos/collector/transports/__init__.py b/sos/collector/transports/__init__.py
index 35962734..9ad4f0c2 100644
--- a/sos/collector/transports/__init__.py
+++ b/sos/collector/transports/__init__.py
@@ -29,6 +29,7 @@ class RemoteTransport():
"""
name = 'undefined'
+ default_user = None
def __init__(self, address, commons):
self.address = address
diff --git a/sos/collector/transports/juju.py b/sos/collector/transports/juju.py
new file mode 100644
index 00000000..bea95fb6
--- /dev/null
+++ b/sos/collector/transports/juju.py
@@ -0,0 +1,84 @@
+# 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 subprocess
+
+from sos.collector.exceptions import JujuNotInstalledException
+from sos.collector.transports import RemoteTransport
+from sos.utilities import sos_get_command_output
+
+
+class JujuSSH(RemoteTransport):
+ """
+ A "transport" that leverages `juju ssh` to perform commands on the remote
+ hosts.
+
+ This transport is expected to be used in juju managed environment, and the
+ user should have the necessary credential for accessing the controller.
+ When using this transport, the --nodes option will be expected to be a
+ comma separated machine IDs, **not** IP addr, since `juju ssh` identifies
+ the ssh target by machine ID.
+
+ Examples:
+
+ sos collect --nodes 0,1,2 --no-local --transport juju --batch
+
+ """
+
+ name = "juju_ssh"
+ default_user = "ubuntu"
+
+ def _check_juju_installed(self):
+ cmd = "juju version"
+ try:
+ subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True)
+ except subprocess.CalledProcessError:
+ self.log_error("Failed to check `juju` version")
+ raise JujuNotInstalledException
+ return True
+
+ def _chmod(self, fname):
+ cmd = f"{self.remote_exec} sudo chmod o+r {fname}"
+ try:
+ subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True)
+ except subprocess.CalledProcessError:
+ self.log_error(f"Failed to make {fname} world-readable")
+ raise
+ return True
+
+ def _connect(self, password=""):
+ self._connected = self._check_juju_installed()
+ return self._connected
+
+ def _disconnect(self):
+ return True
+
+ @property
+ def connected(self):
+ return self._connected
+
+ @property
+ def remote_exec(self):
+ model, target_option = self.address.split(":")
+ model_option = f"-m {model}" if model else ""
+ option = f"{model_option} {target_option}"
+ return f"juju ssh {option}"
+
+ def _retrieve_file(self, fname, dest):
+ self._chmod(fname) # juju scp needs the archive to be world-readable
+ model, unit = self.address.split(":")
+ model_option = f"-m {model}" if model else ""
+ cmd = f"juju scp {model_option} -- -r {unit}:{fname} {dest}"
+ res = sos_get_command_output(cmd)
+ return res["status"] == 0
+
+
+# vim: set et ts=4 sw=4 :
diff --git a/tests/unittests/juju/__init__.py b/tests/unittests/juju/__init__.py
new file mode 100644
index 00000000..e1758700
--- /dev/null
+++ b/tests/unittests/juju/__init__.py
@@ -0,0 +1 @@
+# vim: set et ts=4 sw=4 :
diff --git a/tests/unittests/juju/data/juju_output_sos.json b/tests/unittests/juju/data/juju_output_sos.json
new file mode 100644
index 00000000..908d049a
--- /dev/null
+++ b/tests/unittests/juju/data/juju_output_sos.json
@@ -0,0 +1 @@
+{"model":{"name":"sos","type":"iaas","controller":"local-lxc","cloud":"localhost","region":"localhost","version":"2.9.42","model-status":{"current":"available","since":"06 Apr 2023 11:31:27+08:00"},"sla":"unsupported"},"machines":{"0":{"juju-status":{"current":"started","since":"06 Apr 2023 11:33:59+08:00","version":"2.9.42"},"hostname":"juju-38ab8b-0","dns-name":"10.224.139.234","ip-addresses":["10.224.139.234"],"instance-id":"juju-38ab8b-0","machine-status":{"current":"running","message":"Running","since":"06 Apr 2023 11:31:58+08:00"},"modification-status":{"current":"applied","since":"06 Apr 2023 11:31:56+08:00"},"series":"focal","network-interfaces":{"eth0":{"ip-addresses":["10.224.139.234"],"mac-address":"00:16:3e:52:05:7f","gateway":"10.224.139.1","space":"alpha","is-up":true}},"constraints":"arch=amd64","hardware":"arch=amd64 cores=0 mem=0M"},"2":{"juju-status":{"current":"started","since":"07 Apr 2023 14:41:09+08:00","version":"2.9.42"},"hostname":"juju-38ab8b-2","dns-name":"10.224.139.188","ip-addresses":["10.224.139.188"],"instance-id":"juju-38ab8b-2","machine-status":{"current":"running","message":"Running","since":"07 Apr 2023 14:39:09+08:00"},"modification-status":{"current":"applied","since":"07 Apr 2023 14:39:06+08:00"},"series":"focal","network-interfaces":{"eth0":{"ip-addresses":["10.224.139.188"],"mac-address":"00:16:3e:44:31:2d","gateway":"10.224.139.1","space":"alpha","is-up":true}},"constraints":"arch=amd64","hardware":"arch=amd64 cores=0 mem=0M"},"3":{"juju-status":{"current":"started","since":"07 Apr 2023 14:41:08+08:00","version":"2.9.42"},"hostname":"juju-38ab8b-3","dns-name":"10.224.139.181","ip-addresses":["10.224.139.181"],"instance-id":"juju-38ab8b-3","machine-status":{"current":"running","message":"Running","since":"07 Apr 2023 14:39:09+08:00"},"modification-status":{"current":"applied","since":"07 Apr 2023 14:39:06+08:00"},"series":"focal","network-interfaces":{"eth0":{"ip-addresses":["10.224.139.181"],"mac-address":"00:16:3e:44:80:a9","gateway":"10.224.139.1","space":"alpha","is-up":true}},"constraints":"arch=amd64","hardware":"arch=amd64 cores=0 mem=0M"},"4":{"juju-status":{"current":"started","since":"07 Apr 2023 17:01:12+08:00","version":"2.9.42"},"hostname":"juju-38ab8b-4","dns-name":"10.224.139.114","ip-addresses":["10.224.139.114"],"instance-id":"juju-38ab8b-4","machine-status":{"current":"running","message":"Running","since":"07 Apr 2023 16:59:27+08:00"},"modification-status":{"current":"applied","since":"07 Apr 2023 16:59:26+08:00"},"series":"jammy","network-interfaces":{"eth0":{"ip-addresses":["10.224.139.114"],"mac-address":"00:16:3e:0f:84:45","gateway":"10.224.139.1","space":"alpha","is-up":true}},"constraints":"arch=amd64","hardware":"arch=amd64 cores=0 mem=0M"}},"applications":{"nginx":{"charm":"nginx","series":"jammy","os":"ubuntu","charm-origin":"charmhub","charm-name":"nginx","charm-rev":6,"charm-channel":"stable","exposed":false,"application-status":{"current":"unknown","since":"07 Apr 2023 16:59:17+08:00"},"units":{"nginx/1":{"workload-status":{"current":"unknown","since":"07 Apr 2023 17:01:28+08:00"},"juju-status":{"current":"idle","since":"07 Apr 2023 17:01:28+08:00","version":"2.9.42"},"leader":true,"machine":"4","public-address":"10.224.139.114"}},"endpoint-bindings":{"":"alpha","publish":"alpha"}},"nrpe":{"charm":"nrpe","series":"jammy","os":"ubuntu","charm-origin":"charmhub","charm-name":"nrpe","charm-rev":97,"charm-channel":"stable","exposed":false,"application-status":{"current":"active","message":"Ready","since":"06 Apr 2023 11:38:44+08:00"},"relations":{"general-info":["ubuntu"]},"subordinate-to":["ubuntu"],"endpoint-bindings":{"":"alpha","general-info":"alpha","local-monitors":"alpha","monitors":"alpha","nrpe":"alpha","nrpe-external-master":"alpha"}},"ubuntu":{"charm":"ubuntu","series":"focal","os":"ubuntu","charm-origin":"charmhub","charm-name":"ubuntu","charm-rev":22,"charm-channel":"stable","exposed":false,"application-status":{"current":"active","since":"06 Apr 2023 11:34:02+08:00"},"relations":{"juju-info":["nrpe"]},"units":{"ubuntu/0":{"workload-status":{"current":"active","since":"06 Apr 2023 11:34:02+08:00"},"juju-status":{"current":"idle","since":"06 Apr 2023 11:34:04+08:00","version":"2.9.42"},"leader":true,"machine":"0","public-address":"10.224.139.234","subordinates":{"nrpe/0":{"workload-status":{"current":"active","message":"Ready","since":"06 Apr 2023 11:38:44+08:00"},"juju-status":{"current":"idle","since":"06 Apr 2023 11:34:30+08:00","version":"2.9.42"},"leader":true,"open-ports":["icmp","5666/tcp"],"public-address":"10.224.139.234"}}},"ubuntu/1":{"workload-status":{"current":"active","since":"07 Apr 2023 14:41:13+08:00"},"juju-status":{"current":"idle","since":"07 Apr 2023 14:41:15+08:00","version":"2.9.42"},"machine":"2","public-address":"10.224.139.188","subordinates":{"nrpe/2":{"workload-status":{"current":"active","message":"Ready","since":"07 Apr 2023 14:45:38+08:00"},"juju-status":{"current":"idle","since":"07 Apr 2023 14:41:41+08:00","version":"2.9.42"},"open-ports":["icmp","5666/tcp"],"public-address":"10.224.139.188"}}},"ubuntu/2":{"workload-status":{"current":"active","since":"07 Apr 2023 14:41:11+08:00"},"juju-status":{"current":"idle","since":"07 Apr 2023 14:41:13+08:00","version":"2.9.42"},"machine":"3","public-address":"10.224.139.181","subordinates":{"nrpe/1":{"workload-status":{"current":"active","message":"Ready","since":"07 Apr 2023 14:46:56+08:00"},"juju-status":{"current":"idle","since":"07 Apr 2023 14:41:41+08:00","version":"2.9.42"},"open-ports":["icmp","5666/tcp"],"public-address":"10.224.139.181"}}}}}},"storage":{"storage":{"files/0":{"kind":"filesystem","life":"alive","status":{"current":"attached","since":"06 Apr 2023 11:34:01+08:00"},"persistent":false,"attachments":{"units":{"ubuntu/0":{"machine":"0","location":"/srv/data","life":"alive"}}}},"files/1":{"kind":"filesystem","life":"alive","status":{"current":"attached","since":"07 Apr 2023 14:41:11+08:00"},"persistent":false,"attachments":{"units":{"ubuntu/1":{"machine":"2","location":"/srv/data","life":"alive"}}}},"files/2":{"kind":"filesystem","life":"alive","status":{"current":"attached","since":"07 Apr 2023 14:41:10+08:00"},"persistent":false,"attachments":{"units":{"ubuntu/2":{"machine":"3","location":"/srv/data","life":"alive"}}}}},"filesystems":{"0/0":{"provider-id":"0/0","storage":"files/0","Attachments":{"machines":{"0":{"mount-point":"/srv/data","read-only":false,"life":"alive"}},"units":{"ubuntu/0":{"machine":"0","location":"/srv/data","life":"alive"}}},"pool":"rootfs","size":12464,"life":"alive","status":{"current":"attached","since":"06 Apr 2023 11:34:01+08:00"}},"2/1":{"provider-id":"2/1","storage":"files/1","Attachments":{"machines":{"2":{"mount-point":"/srv/data","read-only":false,"life":"alive"}},"units":{"ubuntu/1":{"machine":"2","location":"/srv/data","life":"alive"}}},"pool":"rootfs","size":10062,"life":"alive","status":{"current":"attached","since":"07 Apr 2023 14:41:11+08:00"}},"3/2":{"provider-id":"3/2","storage":"files/2","Attachments":{"machines":{"3":{"mount-point":"/srv/data","read-only":false,"life":"alive"}},"units":{"ubuntu/2":{"machine":"3","location":"/srv/data","life":"alive"}}},"pool":"rootfs","size":10113,"life":"alive","status":{"current":"attached","since":"07 Apr 2023 14:41:10+08:00"}}}},"controller":{"timestamp":"17:04:14+08:00"}}
diff --git a/tests/unittests/juju/data/juju_output_sos2.json b/tests/unittests/juju/data/juju_output_sos2.json
new file mode 100644
index 00000000..4a41bba1
--- /dev/null
+++ b/tests/unittests/juju/data/juju_output_sos2.json
@@ -0,0 +1 @@
+{"model":{"name":"sos2","type":"iaas","controller":"local-lxc","cloud":"localhost","region":"localhost","version":"2.9.42","model-status":{"current":"available","since":"07 Apr 2023 12:54:21+08:00"},"sla":"unsupported"},"machines":{"0":{"juju-status":{"current":"started","since":"07 Apr 2023 12:57:16+08:00","version":"2.9.42"},"hostname":"juju-1cba19-0","dns-name":"10.224.139.132","ip-addresses":["10.224.139.132"],"instance-id":"juju-1cba19-0","machine-status":{"current":"running","message":"Running","since":"07 Apr 2023 12:55:16+08:00"},"modification-status":{"current":"applied","since":"07 Apr 2023 12:55:13+08:00"},"series":"focal","network-interfaces":{"eth0":{"ip-addresses":["10.224.139.132"],"mac-address":"00:16:3e:55:4b:a9","gateway":"10.224.139.1","space":"alpha","is-up":true}},"constraints":"arch=amd64","hardware":"arch=amd64 cores=0 mem=0M"},"1":{"juju-status":{"current":"started","since":"07 Apr 2023 12:57:17+08:00","version":"2.9.42"},"hostname":"juju-1cba19-1","dns-name":"10.224.139.94","ip-addresses":["10.224.139.94"],"instance-id":"juju-1cba19-1","machine-status":{"current":"running","message":"Running","since":"07 Apr 2023 12:55:16+08:00"},"modification-status":{"current":"applied","since":"07 Apr 2023 12:55:15+08:00"},"series":"focal","network-interfaces":{"eth0":{"ip-addresses":["10.224.139.94"],"mac-address":"00:16:3e:5d:d8:08","gateway":"10.224.139.1","space":"alpha","is-up":true}},"constraints":"arch=amd64","hardware":"arch=amd64 cores=0 mem=0M"}},"applications":{"ubuntu":{"charm":"ubuntu","series":"focal","os":"ubuntu","charm-origin":"charmhub","charm-name":"ubuntu","charm-rev":22,"charm-channel":"stable","exposed":false,"application-status":{"current":"active","since":"07 Apr 2023 12:57:19+08:00"},"units":{"ubuntu/0":{"workload-status":{"current":"active","since":"07 Apr 2023 12:57:19+08:00"},"juju-status":{"current":"idle","since":"07 Apr 2023 12:57:21+08:00","version":"2.9.42"},"leader":true,"machine":"0","public-address":"10.224.139.132"},"ubuntu/1":{"workload-status":{"current":"active","since":"07 Apr 2023 12:57:20+08:00"},"juju-status":{"current":"idle","since":"07 Apr 2023 12:57:22+08:00","version":"2.9.42"},"machine":"1","public-address":"10.224.139.94"}},"version":"20.04"}},"storage":{"storage":{"files/0":{"kind":"filesystem","life":"alive","status":{"current":"attached","since":"07 Apr 2023 12:57:18+08:00"},"persistent":false,"attachments":{"units":{"ubuntu/0":{"machine":"0","location":"/srv/data","life":"alive"}}}},"files/1":{"kind":"filesystem","life":"alive","status":{"current":"attached","since":"07 Apr 2023 12:57:19+08:00"},"persistent":false,"attachments":{"units":{"ubuntu/1":{"machine":"1","location":"/srv/data","life":"alive"}}}}},"filesystems":{"0/0":{"provider-id":"0/0","storage":"files/0","Attachments":{"machines":{"0":{"mount-point":"/srv/data","read-only":false,"life":"alive"}},"units":{"ubuntu/0":{"machine":"0","location":"/srv/data","life":"alive"}}},"pool":"rootfs","size":11131,"life":"alive","status":{"current":"attached","since":"07 Apr 2023 12:57:18+08:00"}},"1/1":{"provider-id":"1/1","storage":"files/1","Attachments":{"machines":{"1":{"mount-point":"/srv/data","read-only":false,"life":"alive"}},"units":{"ubuntu/1":{"machine":"1","location":"/srv/data","life":"alive"}}},"pool":"rootfs","size":11094,"life":"alive","status":{"current":"attached","since":"07 Apr 2023 12:57:19+08:00"}}}},"controller":{"timestamp":"14:16:44+08:00"}}
diff --git a/tests/unittests/juju/juju_cluster_tests.py b/tests/unittests/juju/juju_cluster_tests.py
new file mode 100644
index 00000000..136dc49f
--- /dev/null
+++ b/tests/unittests/juju/juju_cluster_tests.py
@@ -0,0 +1,294 @@
+# 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 pathlib
+import unittest
+from unittest.mock import call, patch
+
+from sos.collector.clusters.juju import _parse_option_string, juju
+from sos.options import ClusterOption
+
+
+class MockOptions:
+
+ def __init__(self):
+ self.cluster_options = []
+
+
+def get_juju_output(model):
+ dir = pathlib.Path(__file__).parent.resolve()
+ with open(dir / "data" / f"juju_output_{model}.json") as f:
+ return f.read()
+
+
+def get_juju_status(cmd):
+ if "-m" in cmd:
+ model = cmd.split()[3]
+ else:
+ model = "sos"
+
+ return {
+ "status": 0,
+ "output": get_juju_output(model),
+ }
+
+
+def test_parse_option_string():
+ result = _parse_option_string(" a,b,c")
+ assert result == ["a", "b", "c"]
+
+ result = _parse_option_string()
+ assert result == []
+
+
+class JujuTest(unittest.TestCase):
+ """Test for juju cluster."""
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_get_nodes_no_filter(self, mock_exec_primary_cmd):
+ """No filter."""
+ mock_opts = MockOptions()
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ assert nodes == []
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_get_nodes_app_filter(self, mock_exec_primary_cmd):
+ """Application filter."""
+ mock_opts = MockOptions()
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="apps",
+ opt_type=str,
+ value="ubuntu",
+ cluster=juju.__name__,
+ )
+ )
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ nodes.sort()
+ assert nodes == [":0", ":2", ":3"]
+ mock_exec_primary_cmd.assert_called_once_with(
+ "juju status --format json"
+ )
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_get_nodes_app_regex_filter(self, mock_exec_primary_cmd):
+ """Application filter."""
+ mock_opts = MockOptions()
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="apps",
+ opt_type=str,
+ value="ubuntu|nginx",
+ cluster=juju.__name__,
+ )
+ )
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ nodes.sort()
+ assert nodes == [":0", ":2", ":3", ":4"]
+ mock_exec_primary_cmd.assert_called_once_with(
+ "juju status --format json"
+ )
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_get_nodes_model_filter_multiple_models(
+ self, mock_exec_primary_cmd
+ ):
+ """Multiple model filter."""
+ mock_opts = MockOptions()
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="models",
+ opt_type=str,
+ value="sos,sos2",
+ cluster=juju.__name__,
+ ),
+ )
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="apps",
+ opt_type=str,
+ value="ubuntu",
+ cluster=juju.__name__,
+ ),
+ )
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ nodes.sort()
+ assert nodes == [
+ "sos2:0",
+ "sos2:1",
+ "sos:0",
+ "sos:2",
+ "sos:3",
+ ]
+ mock_exec_primary_cmd.assert_has_calls(
+ [
+ call("juju status -m sos --format json"),
+ call("juju status -m sos2 --format json"),
+ ]
+ )
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_get_nodes_model_filter(self, mock_exec_primary_cmd):
+ """Model filter."""
+ mock_opts = MockOptions()
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="models",
+ opt_type=str,
+ value="sos",
+ cluster=juju.__name__,
+ )
+ )
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="apps",
+ opt_type=str,
+ value="ubuntu",
+ cluster=juju.__name__,
+ ),
+ )
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ nodes.sort()
+ assert nodes == [
+ "sos:0",
+ "sos:2",
+ "sos:3",
+ ]
+ mock_exec_primary_cmd.assert_has_calls(
+ [
+ call("juju status -m sos --format json"),
+ ]
+ )
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_get_nodes_unit_filter(self, mock_exec_primary_cmd):
+ """Node filter."""
+ mock_opts = MockOptions()
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="units",
+ opt_type=str,
+ value="ubuntu/0,ubuntu/1",
+ cluster=juju.__name__,
+ )
+ )
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ nodes.sort()
+ assert nodes == [":0", ":2"]
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_get_nodes_machine_filter(self, mock_exec_primary_cmd):
+ """Machine filter."""
+ mock_opts = MockOptions()
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="machines",
+ opt_type=str,
+ value="0,2",
+ cluster=juju.__name__,
+ )
+ )
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ nodes.sort()
+ print(nodes)
+ assert nodes == [":0", ":2"]
+
+ @patch(
+ "sos.collector.clusters.juju.juju.exec_primary_cmd",
+ side_effect=get_juju_status,
+ )
+ def test_subordinates(self, mock_exec_primary_cmd):
+ """Subordinate filter."""
+ mock_opts = MockOptions()
+ mock_opts.cluster_options.append(
+ ClusterOption(
+ name="apps",
+ opt_type=str,
+ value="nrpe",
+ cluster=juju.__name__,
+ )
+ )
+ cluster = juju(
+ commons={
+ "tmpdir": "/tmp",
+ "cmdlineopts": mock_opts,
+ }
+ )
+ nodes = cluster.get_nodes()
+ nodes.sort()
+ assert nodes == [":0", ":2", ":3"]
+ mock_exec_primary_cmd.assert_called_once_with(
+ "juju status --format json"
+ )
+
+
+# vim: set et ts=4 sw=4 :
diff --git a/tests/unittests/juju/juju_transports_test.py b/tests/unittests/juju/juju_transports_test.py
new file mode 100644
index 00000000..911a957e
--- /dev/null
+++ b/tests/unittests/juju/juju_transports_test.py
@@ -0,0 +1,85 @@
+# 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 subprocess
+import unittest
+from unittest.mock import patch
+
+from sos.collector.exceptions import JujuNotInstalledException
+from sos.collector.transports.juju import JujuSSH
+
+
+class MockCmdLineOpts(object):
+ ssh_user = "user_abc"
+ sudo_pw = "pw_abc"
+ root_password = "root_pw_abc"
+
+
+class JujuSSHTest(unittest.TestCase):
+ def setUp(self):
+ self.juju_ssh = JujuSSH(
+ commons={
+ "cmdlineopts": MockCmdLineOpts,
+ "tmpdir": "/tmp/sos-juju/",
+ "need_sudo": False,
+ },
+ address="model_abc:unit_abc",
+ )
+
+ @patch("sos.collector.transports.juju.subprocess.check_output")
+ def test_check_juju_installed_err(self, mock_subprocess_check_output):
+ """Raise error if juju is not installed."""
+ mock_subprocess_check_output.side_effect = (
+ subprocess.CalledProcessError(returncode="127", cmd="cmd_abc")
+ )
+ with self.assertRaises(JujuNotInstalledException):
+ self.juju_ssh._check_juju_installed()
+
+ @patch("sos.collector.transports.juju.subprocess.check_output")
+ def test_check_juju_installed_true(self, mock_subprocess_check_output):
+ """Return True if juju is installed."""
+ result = self.juju_ssh._check_juju_installed()
+ assert result
+
+ @patch("sos.collector.transports.juju.subprocess.check_output")
+ def test_chmod(self, mock_subprocess_check_output):
+ self.juju_ssh._chmod(fname="file_abc")
+ mock_subprocess_check_output.assert_called_with(
+ f"{self.juju_ssh.remote_exec} sudo chmod o+r file_abc",
+ stderr=subprocess.STDOUT,
+ shell=True,
+ )
+
+ @patch(
+ "sos.collector.transports.juju.JujuSSH._check_juju_installed",
+ return_value=True,
+ )
+ def test_connect(self, mock_result):
+ self.juju_ssh.connect(password=None)
+ assert self.juju_ssh.connected
+
+ def test_remote_exec(self):
+ assert (
+ self.juju_ssh.remote_exec == "juju ssh -m model_abc unit_abc"
+ )
+
+ @patch(
+ "sos.collector.transports.juju.sos_get_command_output",
+ return_value={"status": 0},
+ )
+ @patch("sos.collector.transports.juju.JujuSSH._chmod", return_value=True)
+ def test_retrieve_file(self, mock_chmod, mock_sos_get_cmd_output):
+ self.juju_ssh._retrieve_file(fname="file_abc", dest="/tmp/sos-juju/")
+ mock_sos_get_cmd_output.assert_called_with(
+ "juju scp -m model_abc -- -r unit_abc:file_abc /tmp/sos-juju/"
+ )
+
+
+# vim: set et ts=4 sw=4 :