Merge ~martin-kalcok/juju-lint:unit-tests into juju-lint:master

Proposed by Martin Kalcok
Status: Merged
Approved by: Eric Chen
Approved revision: e504493c6d8167689398da8d07e6113daad9529d
Merged at revision: 3d7a202f2f005c8fc5e9df2e926b6b5001347458
Proposed branch: ~martin-kalcok/juju-lint:unit-tests
Merge into: juju-lint:master
Diff against target: 2367 lines (+1793/-195)
17 files modified
jujulint/check_spaces.py (+1/-3)
jujulint/cli.py (+1/-1)
jujulint/cloud.py (+3/-4)
jujulint/k8s.py (+9/-19)
jujulint/lint.py (+31/-18)
jujulint/logging.py (+15/-19)
jujulint/openstack.py (+1/-5)
tests/conftest.py (+12/-2)
tests/requirements.txt (+1/-2)
tests/test_check_spaces.py (+347/-0)
tests/test_cli.py (+377/-3)
tests/test_cloud.py (+495/-112)
tests/test_jujulint.py (+225/-7)
tests/test_k8s.py (+29/-0)
tests/test_logging.py (+216/-0)
tests/test_openstack.py (+29/-0)
tox.ini (+1/-0)
Reviewer Review Type Date Requested Status
Eric Chen Approve
Gabriel Cocenza Approve
BootStack Reviewers Pending
Review via email: mp+427760@code.launchpad.net

Commit message

Expanded Unit Test coverage.

Description of the change

This change is mostly about adding unit tests. I also made few minor changes to the main codebase
 where it seemed necessary and where it did not change the functionality. I'll add inline comments to any changes to the main code, explaining why I think it should be changed.

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

Thanks for the effort Martin.

I'll review on parts and those comments are regarding the test_check_spaces module

review: Needs Fixing
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

These comments refers to all other modules with the exception of the logging.py that I'll review tomorrow.

review: Needs Fixing
Revision history for this message
Martin Kalcok (martin-kalcok) wrote :

Thanks for your effort Gabriel. I forgot to save my inline comments explaining the few changes I made to the main codebase. I'll add them now, sorry.

Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

These are my final comments about the logging.py module.

I also would like to discuss what should we do to force coverage (--cov-fail-under=MIN) on next MRs. IMO, we should consider these two approaches:

1 - Fixing the coverage percentage to what we have right now (95.13) on the tox command for unit tests

2 - add a "# pragma: no cover" on the lines missing unit tests with a message TODO to cover in the future and limit it with 100 percentage.

I slightly prefer the second option.

Revision history for this message
Martin Kalcok (martin-kalcok) :
Revision history for this message
Gabriel Cocenza (gabrielcocenza) :
Revision history for this message
Martin Kalcok (martin-kalcok) :
Revision history for this message
Eric Chen (eric-chen) wrote :

LGTM, really good job

There is only small suggestion, please change it, then we can merge it into juju-lint asap.

review: Needs Fixing
Revision history for this message
Martin Kalcok (martin-kalcok) wrote :

I updated code according to your comments Erik. Thanks for your review.

I also marked all currently remaining untested code with "#pragma: no cover" and set minimum 100% coverage in tox.ini so that we can require full test coverage on any future PRs.

Revision history for this message
Eric Chen (eric-chen) wrote :

A obvious little mistake

review: Needs Fixing
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

I think this PR needs to fix some merge conflicts. Other than that, LGTM.

review: Needs Fixing
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

LGTM, but it's not passing lint test. Once you use "make dev-environment" it will install pre-commit and start catching this kind of issue.

Thanks!

review: Approve
Revision history for this message
Eric Chen (eric-chen) :
review: Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision 3d7a202f2f005c8fc5e9df2e926b6b5001347458

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/jujulint/check_spaces.py b/jujulint/check_spaces.py
index 975661d..0564508 100644
--- a/jujulint/check_spaces.py
+++ b/jujulint/check_spaces.py
@@ -23,9 +23,7 @@ class Relation:
23 While Juju does define separate provider and requirer roles, we'll ignore23 While Juju does define separate provider and requirer roles, we'll ignore
24 those here.24 those here.
25 """25 """
26 return set([self.endpoint1, self.endpoint2]) == set(26 return {self.endpoint1, self.endpoint2} == {other.endpoint1, other.endpoint2}
27 [other.endpoint1, other.endpoint2]
28 )
2927
30 @property28 @property
31 def endpoints(self):29 def endpoints(self):
diff --git a/jujulint/cli.py b/jujulint/cli.py
index 97e5a34..2ea188a 100755
--- a/jujulint/cli.py
+++ b/jujulint/cli.py
@@ -197,5 +197,5 @@ def main():
197 cli.usage()197 cli.usage()
198198
199199
200if __name__ == "__main__":200if __name__ == "__main__": # pragma: no cover
201 main()201 main()
diff --git a/jujulint/cloud.py b/jujulint/cloud.py
index 6d0ec44..490f4d2 100644
--- a/jujulint/cloud.py
+++ b/jujulint/cloud.py
@@ -61,10 +61,9 @@ class Cloud:
61 # instance variables61 # instance variables
62 self.cloud_state = {}62 self.cloud_state = {}
63 self.access_method = "local"63 self.access_method = "local"
64 self.ssh_host = ""
65 self.sudo_user = ""64 self.sudo_user = ""
66 self.hostname = ""65 self.hostname = ""
67 self.name = ""66 self.name = name
68 self.fabric_config = {}67 self.fabric_config = {}
69 self.lint_rules = lint_rules68 self.lint_rules = lint_rules
70 self.lint_overrides = lint_overrides69 self.lint_overrides = lint_overrides
@@ -86,7 +85,6 @@ class Cloud:
86 self.access_method = "ssh"85 self.access_method = "ssh"
87 elif access_method == "local":86 elif access_method == "local":
88 self.hostname = socket.getfqdn()87 self.hostname = socket.getfqdn()
89 self.name = name
9088
91 def run_command(self, command):89 def run_command(self, command):
92 """Run a command via fabric on the local or remote host."""90 """Run a command via fabric on the local or remote host."""
@@ -126,7 +124,8 @@ class Cloud:
126 def run_unit_command(self, target, command):124 def run_unit_command(self, target, command):
127 """Run a command on a Juju unit and return the output."""125 """Run a command on a Juju unit and return the output."""
128126
129 def parse_yaml(self, yaml_string):127 @staticmethod
128 def parse_yaml(yaml_string):
130 """Parse YAML using PyYAML."""129 """Parse YAML using PyYAML."""
131 data = yaml.safe_load_all(yaml_string)130 data = yaml.safe_load_all(yaml_string)
132 return list(data)131 return list(data)
diff --git a/jujulint/k8s.py b/jujulint/k8s.py
index 53b8c30..9303fe6 100644
--- a/jujulint/k8s.py
+++ b/jujulint/k8s.py
@@ -38,28 +38,18 @@ Todo:
38from jujulint.cloud import Cloud38from jujulint.cloud import Cloud
3939
4040
41class OpenStack(Cloud):41class Kubernetes(Cloud):
42 """Helper class for interacting with Nagios via the livestatus socket."""42 """Specialized subclass of Cloud with helpers related to Kubernetes."""
4343
44 def __init__(self, *args, **kwargs):44 def __init__(self, *args, **kwargs):
45 """Initialise class-local variables and configuration and pass to super."""45 """Initialise class-local variables and configuration and pass to super."""
46 super(OpenStack, self).__init__(*args, **kwargs)46 super(Kubernetes, self).__init__(*args, **kwargs)
4747 self.cloud_type = "kubernetes"
48 def get_neutron_ports(self):
49 """Get a list of neutron ports."""
50
51 def get_neutron_routers(self):
52 """Get a list of neutron routers."""
53
54 def get_neutron_networks(self):
55 """Get a list of neutron networks."""
56
57 def refresh(self):
58 """Refresh cloud information."""
59 return super(OpenStack, self).refresh()
6048
61 def audit(self):49 def audit(self):
62 """Audit OpenStack cloud and run base Cloud audits."""50 """Audit OpenStack cloud and run base Cloud audits."""
63 # add specific OpenStack checks here51 # add specific Kubernetes checks here
64 self.logger.debug("Running OpenStack-specific audit steps.")52 self.logger.info(
65 super(OpenStack, self).audit()53 "[{}] Running Kubernetes-specific audit steps.".format(self.name)
54 )
55 super(Kubernetes, self).audit()
diff --git a/jujulint/lint.py b/jujulint/lint.py
index 8f25617..a931d55 100755
--- a/jujulint/lint.py
+++ b/jujulint/lint.py
@@ -177,12 +177,19 @@ class Linter:
177177
178 return178 return
179179
180 def atoi(self, val):180 @staticmethod
181 """Deal with complex number representations as strings, returning a number."""181 def atoi(val):
182 if type(val) != str:182 """Deal with complex number representations as strings.
183 return val
184183
185 if type(val[-1]) != str:184 This method attempts to convert string containing number and a
185 supported suffix (k,m,g,K,M,G) into a int with appropriate value.
186 e.g.: "2k" -> 2000
187 "2K" -> 2048
188
189 If the input value does not match the expected format, it is returned
190 without the change.
191 """
192 if type(val) != str:
186 return val193 return val
187194
188 try:195 try:
@@ -190,13 +197,17 @@ class Linter:
190 except Exception:197 except Exception:
191 return val198 return val
192199
200 suffix = val[-1]
193 quotient = 1024201 quotient = 1024
194 if val[-1].lower() == val[-1]:202 if suffix.islower():
195 quotient = 1000203 quotient = 1000
196204
197 conv = {"g": quotient**3, "m": quotient**2, "k": quotient}205 conv = {"g": quotient**3, "m": quotient**2, "k": quotient}
198206
199 return _int * conv[val[-1].lower()]207 if suffix.lower() not in conv:
208 return val
209
210 return _int * conv[suffix.lower()]
200211
201 def isset(self, name, check_value, rule, config):212 def isset(self, name, check_value, rule, config):
202 """Check if value is set per rule constraints."""213 """Check if value is set per rule constraints."""
@@ -293,8 +304,8 @@ class Linter:
293304
294 def search(self, app_name, check_value, config_key, app_config):305 def search(self, app_name, check_value, config_key, app_config):
295 """Scan through the charm config looking for a match using the regex pattern."""306 """Scan through the charm config looking for a match using the regex pattern."""
296 actual_value = app_config.get(config_key)307 if config_key in app_config:
297 if actual_value:308 actual_value = app_config.get(config_key)
298 if re.search(str(check_value), str(actual_value)):309 if re.search(str(check_value), str(actual_value)):
299 self._log_with_header(310 self._log_with_header(
300 "Application {} has a valid config for '{}': regex {} found at {}".format(311 "Application {} has a valid config for '{}': regex {} found at {}".format(
@@ -450,7 +461,7 @@ class Linter:
450461
451 if self.cloud_type == "openstack":462 if self.cloud_type == "openstack":
452 # process openstack config rules463 # process openstack config rules
453 if "openstack config" in self.lint_rules:464 if "openstack config" in self.lint_rules: # pragma: no cover
454 if charm_name in self.lint_rules["openstack config"]:465 if charm_name in self.lint_rules["openstack config"]:
455 lint_rules.extend(466 lint_rules.extend(
456 self.lint_rules["openstack config"][charm_name].items()467 self.lint_rules["openstack config"][charm_name].items()
@@ -464,7 +475,7 @@ class Linter:
464 lint_rules,475 lint_rules,
465 )476 )
466477
467 def check_subs(self, machines_data):478 def check_subs(self, machines_data): # pragma: no cover
468 """Check the subordinates in the model."""479 """Check the subordinates in the model."""
469 all_or_nothing = set()480 all_or_nothing = set()
470 for machine in self.model.subs_on_machines:481 for machine in self.model.subs_on_machines:
@@ -790,7 +801,7 @@ class Linter:
790 )801 )
791 except Exception:802 except Exception:
792 # FOR NOW: super quick and dirty803 # FOR NOW: super quick and dirty
793 print(804 self.logger.warn(
794 "Exception caught during space check; please check space by hand. {}".format(805 "Exception caught during space check; please check space by hand. {}".format(
795 traceback.format_exc()806 traceback.format_exc()
796 )807 )
@@ -1034,7 +1045,7 @@ class Linter:
1034 data_d["juju-status"],1045 data_d["juju-status"],
1035 expected=juju_expected,1046 expected=juju_expected,
1036 )1047 )
1037 else:1048 else: # pragma: no cover
1038 self._log_with_header(1049 self._log_with_header(
1039 "Could not determine Juju status for {}.".format(name),1050 "Could not determine Juju status for {}.".format(name),
1040 level=logging.WARN,1051 level=logging.WARN,
@@ -1055,7 +1066,7 @@ class Linter:
1055 )1066 )
1056 for container_name in juju_status["machines"][machine_name].get(1067 for container_name in juju_status["machines"][machine_name].get(
1057 "container", []1068 "container", []
1058 ):1069 ): # pragma: no cover
1059 self.check_status_pair(1070 self.check_status_pair(
1060 container_name,1071 container_name,
1061 "container",1072 "container",
@@ -1117,7 +1128,7 @@ class Linter:
1117 for unit in applications[app_name]["units"]:1128 for unit in applications[app_name]["units"]:
1118 machine = applications[app_name]["units"][unit]["machine"]1129 machine = applications[app_name]["units"][unit]["machine"]
1119 machine = machine.split("/")[0]1130 machine = machine.split("/")[0]
1120 if machine not in self.model.machines_to_az:1131 if machine not in self.model.machines_to_az: # pragma: no cover
1121 self._log_with_header(1132 self._log_with_header(
1122 "{}: Can't find machine {} in machine to AZ mapping data".format(1133 "{}: Can't find machine {} in machine to AZ mapping data".format(
1123 app_name,1134 app_name,
@@ -1164,9 +1175,11 @@ class Linter:
1164 parsed_yaml = self.get_main_bundle_doc(parsed_yaml_docs)1175 parsed_yaml = self.get_main_bundle_doc(parsed_yaml_docs)
1165 if parsed_yaml:1176 if parsed_yaml:
1166 return self.do_lint(parsed_yaml)1177 return self.do_lint(parsed_yaml)
1167 self.logger.fubar("Failed to parse YAML from file {}".format(filename))1178 self.logger.fubar(
1179 "Failed to parse YAML from file {}".format(filename)
1180 ) # pragma: no cover
11681181
1169 def do_lint(self, parsed_yaml):1182 def do_lint(self, parsed_yaml): # pragma: no cover
1170 """Lint parsed YAML."""1183 """Lint parsed YAML."""
1171 # Handle Juju 2 vs Juju 11184 # Handle Juju 2 vs Juju 1
1172 applications = "applications"1185 applications = "applications"
@@ -1260,7 +1273,7 @@ class Linter:
1260 if line.startswith("!include"):1273 if line.startswith("!include"):
1261 try:1274 try:
1262 _, rel_path = line.split()1275 _, rel_path = line.split()
1263 except ValueError:1276 except ValueError: # pragma: no cover
1264 self.logger.warn(1277 self.logger.warn(
1265 "invalid include in rules, ignored: '{}'".format(line)1278 "invalid include in rules, ignored: '{}'".format(line)
1266 )1279 )
diff --git a/jujulint/logging.py b/jujulint/logging.py
index 1d1586d..7ee3e56 100644
--- a/jujulint/logging.py
+++ b/jujulint/logging.py
@@ -49,27 +49,23 @@ class Logger:
49 console.setFormatter(colour_formatter)49 console.setFormatter(colour_formatter)
50 self.logger.addHandler(console)50 self.logger.addHandler(console)
51 if logfile:51 if logfile:
52 try:52 file_logger = colorlog.getLogger("file")
53 file_logger = colorlog.getLogger("file")53 plain_formatter = logging.Formatter(format_string, datefmt=date_format)
54 plain_formatter = logging.Formatter(54 # If we send output to the file logger specifically, don't propagate it
55 format_string, datefmt=date_format55 # to the root logger as well to avoid duplicate output. So if we want
56 )56 # to only send logging output to the file, you would do this:
57 # If we send output to the file logger specifically, don't propagate it57 # logging.getLogger('file').info("message for logfile only")
58 # to the root logger as well to avoid duplicate output. So if we want58 # rather than this:
59 # to only send logging output to the file, you would do this:59 # logging.info("message for console and logfile")
60 # logging.getLogger('file').info("message for logfile only")60 file_logger.propagate = False
61 # rather than this:
62 # logging.info("message for console and logfile")
63 file_logger.propagate = False
6461
65 file_handler = logging.FileHandler(logfile)62 file_handler = logging.FileHandler(logfile)
66 file_handler.setFormatter(plain_formatter)63 file_handler.setFormatter(plain_formatter)
67 self.logger.addHandler(file_handler)64 self.logger.addHandler(file_handler)
68 file_logger.addHandler(file_handler)65 file_logger.addHandler(file_handler)
69 except IOError:
70 logging.error("Unable to write to logfile: {}".format(logfile))
7166
72 def fubar(self, msg, exit_code=1):67 @staticmethod
68 def fubar(msg, exit_code=1):
73 """Exit and print to stderr because everything is FUBAR."""69 """Exit and print to stderr because everything is FUBAR."""
74 sys.stderr.write("E: %s\n" % (msg))70 sys.stderr.write("E: %s\n" % (msg))
75 sys.exit(exit_code)71 sys.exit(exit_code)
diff --git a/jujulint/openstack.py b/jujulint/openstack.py
index 398721e..5a87252 100644
--- a/jujulint/openstack.py
+++ b/jujulint/openstack.py
@@ -42,7 +42,7 @@ from jujulint.cloud import Cloud
4242
4343
44class OpenStack(Cloud):44class OpenStack(Cloud):
45 """Helper class for interacting with Nagios via the livestatus socket."""45 """Specialized subclass of Cloud with helpers related to OpenStack."""
4646
47 def __init__(self, *args, **kwargs):47 def __init__(self, *args, **kwargs):
48 """Initialise class-local variables and configuration and pass to super."""48 """Initialise class-local variables and configuration and pass to super."""
@@ -58,10 +58,6 @@ class OpenStack(Cloud):
58 def get_neutron_networks(self):58 def get_neutron_networks(self):
59 """Get a list of neutron networks."""59 """Get a list of neutron networks."""
6060
61 def refresh(self):
62 """Refresh cloud information."""
63 return super(OpenStack, self).refresh()
64
65 def audit(self):61 def audit(self):
66 """Audit OpenStack cloud and run base Cloud audits."""62 """Audit OpenStack cloud and run base Cloud audits."""
67 # add specific OpenStack checks here63 # add specific OpenStack checks here
diff --git a/tests/conftest.py b/tests/conftest.py
index b43a273..d82193d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -19,6 +19,8 @@ import pytest
19test_path = os.path.dirname(os.path.abspath(__file__))19test_path = os.path.dirname(os.path.abspath(__file__))
20sys.path.insert(0, test_path + "/../")20sys.path.insert(0, test_path + "/../")
2121
22from jujulint import cloud # noqa: E402
23
2224
23@pytest.fixture25@pytest.fixture
24def mocked_pkg_resources(monkeypatch):26def mocked_pkg_resources(monkeypatch):
@@ -29,7 +31,7 @@ def mocked_pkg_resources(monkeypatch):
2931
3032
31@pytest.fixture33@pytest.fixture
32def cli(monkeypatch):34def cli_instance(monkeypatch):
33 """Provide a test instance of the CLI class."""35 """Provide a test instance of the CLI class."""
34 monkeypatch.setattr(36 monkeypatch.setattr(
35 sys, "argv", ["juju-lint", "-c", "contrib/canonical-rules.yaml"]37 sys, "argv", ["juju-lint", "-c", "contrib/canonical-rules.yaml"]
@@ -77,7 +79,7 @@ def linter(parser):
7779
7880
79@pytest.fixture81@pytest.fixture
80def cloud():82def cloud_instance():
81 """Provide a Cloud instance to test."""83 """Provide a Cloud instance to test."""
82 from jujulint.cloud import Cloud84 from jujulint.cloud import Cloud
8385
@@ -210,3 +212,11 @@ def juju_export_bundle():
210 }212 }
211 ],213 ],
212 }214 }
215
216
217@pytest.fixture()
218def patch_cloud_init(mocker):
219 """Patch objects needed in Cloud.__init__() method."""
220 mocker.patch.object(cloud, "Logger")
221 mocker.patch.object(cloud, "Connection")
222 mocker.patch.object(cloud.socket, "getfqdn", return_value="localhost")
diff --git a/tests/requirements.txt b/tests/requirements.txt
index d31d3fb..ce434c0 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,14 +1,13 @@
1# Module requirements1# Module requirements
2black2black
3colorama3colorama
4flake84flake8<5.0 # some flake8 plugins seem to be currently broken with version 5.0+
5flake8-colors5flake8-colors
6flake8-docstrings6flake8-docstrings
7flake8-html7flake8-html
8isort8isort
9mock9mock
10pep8-naming10pep8-naming
11pycodestyle
12pyflakes11pyflakes
13pytest12pytest
14pytest-cov13pytest-cov
diff --git a/tests/test_check_spaces.py b/tests/test_check_spaces.py
15new file mode 10064414new file mode 100644
index 0000000..9b77e66
--- /dev/null
+++ b/tests/test_check_spaces.py
@@ -0,0 +1,347 @@
1"""Tests for check_spaces.py module."""
2from unittest.mock import call
3
4import pytest
5
6from jujulint import check_spaces
7
8
9def test_relation_init():
10 """Test initiation of Relation instance."""
11 ep_1 = "Endpoint 1"
12 ep_2 = "Endpoint 2"
13
14 relation = check_spaces.Relation(ep_1, ep_2)
15
16 assert relation.endpoint1 == ep_1
17 assert relation.endpoint2 == ep_2
18
19
20def test_relation_str():
21 """Test expected string representation of a Relation class."""
22 ep_1 = "Endpoint 1"
23 ep_2 = "Endpoint 2"
24 expected_str = "Relation({} - {})".format(ep_1, ep_2)
25
26 relation = check_spaces.Relation(ep_1, ep_2)
27
28 assert str(relation) == expected_str
29
30
31@pytest.mark.parametrize(
32 "rel_1_ep_1, rel_1_ep_2, rel_2_ep_1, rel_2_ep_2, expected_result",
33 [
34 (
35 "same endpoint 1",
36 "same endpoint 2",
37 "same endpoint 1",
38 "same endpoint 2",
39 True,
40 ),
41 (
42 "same endpoint 1",
43 "same endpoint 2",
44 "same endpoint 1",
45 "different endpoint 2",
46 False,
47 ),
48 (
49 "same endpoint 1",
50 "same endpoint 2",
51 "different endpoint 1",
52 "different endpoint 2",
53 False,
54 ),
55 (
56 "same endpoint 1",
57 "same endpoint 2",
58 "different endpoint 1",
59 "same endpoint 2",
60 False,
61 ),
62 ],
63)
64def test_relation_eq(rel_1_ep_1, rel_1_ep_2, rel_2_ep_1, rel_2_ep_2, expected_result):
65 """Test equality operator of Relation class. Only return true if both endpoints match."""
66 relation_1 = check_spaces.Relation(rel_1_ep_1, rel_1_ep_2)
67 relation_2 = check_spaces.Relation(rel_2_ep_1, rel_2_ep_2)
68
69 assert (relation_1 == relation_2) == expected_result
70
71
72def test_relation_endpoints_prop():
73 """Test "endpoints" property of a Relation class."""
74 ep_1 = "Endpoint 1"
75 ep_2 = "Endpoint 2"
76
77 relation = check_spaces.Relation(ep_1, ep_2)
78
79 assert relation.endpoints == [ep_1, ep_2]
80
81
82@pytest.mark.parametrize(
83 "input_order, output_order",
84 [
85 # Input endpoints are already in alphabetical order, output unchanged
86 (
87 ["A EP", "A Space", "Z EP", "Z Space"],
88 ["A EP", "A Space", "Z EP", "Z Space"],
89 ),
90 # Input endpoints are not in order, output is alphabetically reordered
91 (
92 ["Z EP", "Z Space", "A EP", "A Space"],
93 ["A EP", "A Space", "Z EP", "Z Space"],
94 ),
95 # Input endpoints are the same, no reordering occurs on output
96 (
97 ["Z EP", "A Space", "Z EP", "Z Space"],
98 ["Z EP", "A Space", "Z EP", "Z Space"],
99 ),
100 ],
101)
102def test_space_mismatch_init(input_order, output_order):
103 """Test initiation of SpaceMismatch class.
104
105 This test also verifies that spaces in SpaceMismatch instance are ordered
106 alphabetically based on the endpoint name.
107 """
108 mismatch_instance = check_spaces.SpaceMismatch(*input_order)
109
110 # Assert that endpoints are alphabetically reordered
111 assert mismatch_instance.endpoint1 == output_order[0]
112 assert mismatch_instance.space1 == output_order[1]
113 assert mismatch_instance.endpoint2 == output_order[2]
114 assert mismatch_instance.space2 == output_order[3]
115
116
117def test_space_mismatch_str():
118 """Test string representation of a SpaceMismatch class."""
119 ep_1 = "Endpoint 1"
120 ep_2 = "Endpoint 2"
121 space_1 = "Space 1"
122 space_2 = "Space 2"
123 expected_str = "SpaceMismatch({} (space {}) != {} (space {}))".format(
124 ep_1, space_1, ep_2, space_2
125 )
126
127 mismatch_instance = check_spaces.SpaceMismatch(ep_1, space_1, ep_2, space_2)
128
129 assert str(mismatch_instance) == expected_str
130
131
132def test_space_mismatch_relation_prop():
133 """Test relation property of a SpaceMismatch class."""
134 ep_1 = "Endpoint 1"
135 ep_2 = "Endpoint 2"
136 space_1 = "Space 1"
137 space_2 = "Space 2"
138
139 expected_relation = check_spaces.Relation(ep_1, ep_2)
140
141 mismatch_instance = check_spaces.SpaceMismatch(ep_1, space_1, ep_2, space_2)
142
143 assert mismatch_instance.relation == expected_relation
144
145
146def test_space_mismatch_get_charm_relation():
147 """Test get_charm_relation method of SpaceMismatch."""
148 app_1 = "ubuntu_server"
149 charm_1 = "ubuntu"
150 app_2 = "ubuntu_nrpe"
151 charm_2 = "nrpe"
152 ep_1 = app_1 + ":endpoint_1"
153 ep_2 = app_2 + ":endpoint_2"
154 space_1 = "Space 1"
155 space_2 = "Space 2"
156
157 app_map = {app_1: charm_1, app_2: charm_2}
158
159 expected_relation = check_spaces.Relation("ubuntu:endpoint_1", "nrpe:endpoint_2")
160
161 mismatch_instance = check_spaces.SpaceMismatch(ep_1, space_1, ep_2, space_2)
162
163 assert mismatch_instance.get_charm_relation(app_map) == expected_relation
164
165
166@pytest.mark.parametrize("use_cmr", [True, False])
167def test_find_space_mismatches(use_cmr, mocker):
168 """Test function find_space_mismatches()."""
169 sample_yaml = "sample yaml"
170 app_1 = "ubuntu_server"
171 app_2 = "ubuntu_nrpe"
172 space_1 = "space 1"
173 space_2 = "space 2"
174 app_endpoint_1 = app_1 + ":endpoint"
175 app_endpoint_2 = app_2 + ":endpoint"
176 relation = check_spaces.Relation(
177 app_endpoint_1, "XModel" if use_cmr else app_endpoint_2
178 )
179 app_list = [app_1, app_2]
180 app_spaces = {app_1: {space_1: "foo"}, app_2: {space_2: "bar"}}
181
182 app_list_mock = mocker.patch.object(
183 check_spaces, "get_juju_applications", return_value=app_list
184 )
185 app_spaces_mock = mocker.patch.object(
186 check_spaces, "get_application_spaces", return_value=app_spaces
187 )
188 rel_list_mock = mocker.patch.object(
189 check_spaces, "get_application_relations", return_value=[relation]
190 )
191 rel_space_mock = mocker.patch.object(
192 check_spaces, "get_relation_space", side_effect=[space_1, space_2]
193 )
194
195 expected_mismatch = [
196 check_spaces.SpaceMismatch(
197 relation.endpoint1, space_1, relation.endpoint2, space_2
198 )
199 ]
200
201 mismatch = check_spaces.find_space_mismatches(sample_yaml, True)
202 result_pairs = zip(expected_mismatch, mismatch)
203
204 app_list_mock.assert_called_once_with(sample_yaml)
205 app_spaces_mock.assert_called_once_with(app_list, sample_yaml)
206 rel_list_mock.assert_called_once_with(sample_yaml)
207 rel_space_mock.assert_has_calls(
208 [
209 call(relation.endpoint1, app_spaces),
210 call(relation.endpoint2, app_spaces),
211 ]
212 )
213 for expected_result, actual_result in result_pairs:
214 assert str(expected_result) == str(actual_result)
215
216
217def test_get_juju_applications():
218 """Test parsing applications from yaml status."""
219 app_1 = "ubuntu"
220 app_2 = "nrpe"
221 sample_yaml = {
222 "applications": {app_1: {"charm-url": "ch:foo"}, app_2: {"charm-url": "ch:bar"}}
223 }
224
225 expected_apps = [app_1, app_2]
226
227 apps = check_spaces.get_juju_applications(sample_yaml)
228
229 assert apps == expected_apps
230
231
232def test_get_application_spaces(mocker):
233 """Test function that returns map of applications and their bindings.
234
235 This test also verifies that default binding to space "alpha" is added to applications
236 that do not specify any bindings.
237 """
238 logger_mock = mocker.patch.object(check_spaces, "LOGGER")
239 default_binding = ""
240 default_space = "custom_default_space"
241 public_binding = "public"
242 public_space = "public_space"
243 app_list = ["ubuntu", "nrpe", "mysql"]
244 sample_yaml = {
245 "applications": {
246 # App with proper bindings
247 app_list[0]: {
248 "bindings": {
249 default_binding: default_space,
250 public_binding: public_space,
251 }
252 },
253 # App with missing default bindings
254 app_list[1]: {
255 "bindings": {
256 public_binding: public_space,
257 }
258 },
259 # App without any bindings defined
260 app_list[2]: {},
261 }
262 }
263
264 expected_app_spaces = {
265 app_list[0]: {default_binding: default_space, public_binding: public_space},
266 app_list[1]: {
267 public_binding: public_space,
268 },
269 app_list[2]: {default_binding: "alpha"},
270 }
271
272 app_spaces = check_spaces.get_application_spaces(app_list, sample_yaml)
273
274 # Verify that all the bindings for properly defined app were returned
275 # Verify that default binding was added to app that did not have any bindings defined
276 # Verify that Warning was logged for app without explicit default binding
277 # Verify that Warnings were logged for app without any bindings
278
279 assert app_spaces == expected_app_spaces
280 logger_mock.warning.assert_has_calls(
281 [
282 call(
283 "Application %s does not define explicit default binding", app_list[1]
284 ),
285 call("Application %s is missing explicit bindings", app_list[2]),
286 call("Setting default binding of '%s' to alpha", app_list[2]),
287 ]
288 )
289
290
291def test_get_application_relations():
292 """Test function that returns list of relations."""
293 sample_yaml = {
294 "relations": [
295 ["ubuntu:juju-info", "nrpe:general-info"],
296 ["vault:shared-db", "mysql-innodb-cluster:shared-db"],
297 ]
298 }
299
300 expected_relations = [
301 check_spaces.Relation("ubuntu:juju-info", "nrpe:general-info"),
302 check_spaces.Relation("vault:shared-db", "mysql-innodb-cluster:shared-db"),
303 ]
304
305 relations = check_spaces.get_application_relations(sample_yaml)
306
307 assert relations == expected_relations
308
309
310@pytest.mark.parametrize("use_explicit_binding", [True, False])
311def test_get_relation_space(use_explicit_binding):
312 """Test getting space for a specific binding."""
313 app_name = "ubuntu"
314 interface = "juju_info"
315 default_space = "alpha"
316 endpoint = app_name + ":" + interface
317
318 app_spaces = {"ubuntu": {"": default_space}}
319
320 if use_explicit_binding:
321 expected_space = "custom_space"
322 app_spaces["ubuntu"][interface] = expected_space
323 else:
324 expected_space = default_space
325
326 space = check_spaces.get_relation_space(endpoint, app_spaces)
327
328 assert space == expected_space
329
330
331def test_get_relation_space_cmr(mocker):
332 """Test getting space for cross model relation."""
333 logger_mock = mocker.patch.object(check_spaces, "LOGGER")
334 app_name = "ubuntu"
335 interface = "juju_info"
336 endpoint = app_name + ":" + interface
337
338 app_spaces = {}
339
340 space = check_spaces.get_relation_space(endpoint, app_spaces)
341
342 assert space == "XModel"
343 logger_mock.warning.assert_called_once_with(
344 "Multi-model is not supported yet. Please check "
345 "if '%s' is from another model",
346 app_name,
347 )
diff --git a/tests/test_cli.py b/tests/test_cli.py
index c5a9e95..ed63f67 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,7 +1,11 @@
1#!/usr/bin/python31#!/usr/bin/python3
2"""Test the CLI."""2"""Test the CLI."""
3from logging import WARN
4from unittest.mock import MagicMock, call
35
4from jujulint.cli import Cli6import pytest
7
8from jujulint import cli
59
610
7def test_pytest():11def test_pytest():
@@ -9,6 +13,376 @@ def test_pytest():
9 assert True13 assert True
1014
1115
12def test_cli_fixture(cli):16def test_cli_fixture(cli_instance):
13 """Test if the CLI fixture works."""17 """Test if the CLI fixture works."""
14 assert isinstance(cli, Cli)18 assert isinstance(cli_instance, cli.Cli)
19
20
21@pytest.mark.parametrize("output_format_value", ["text", "json"])
22def test_cli_init(output_format_value, mocker):
23 """Test initiation of CLI class."""
24 logging_mock = mocker.patch.object(cli, "logging")
25
26 loglevel_value = "warn"
27 loglevel = MagicMock()
28 loglevel.get.return_value = loglevel_value
29
30 output_format = MagicMock()
31 output_format.get.return_value = output_format_value
32
33 rules_file_value = "/tmp/rules.yaml"
34 rules_file = MagicMock()
35 rules_file.get.return_value = rules_file_value
36
37 config = {
38 "logging": {"loglevel": loglevel},
39 "format": output_format,
40 "rules": {"file": rules_file},
41 }
42
43 mocker.patch.object(cli, "Config", return_value=config)
44 mocker.patch.object(cli.os.path, "isfile", return_value=True)
45
46 cli_instance = cli.Cli()
47
48 assert cli_instance.logger.logger.level == WARN
49 assert cli_instance.output_format == output_format_value
50 assert cli_instance.lint_rules == rules_file_value
51
52 if output_format_value != "text":
53 logging_mock.disable.assert_called_once_with(level=logging_mock.CRITICAL)
54
55
56@pytest.mark.parametrize("version", ["1.0", None])
57def test_cli_ini_version(version, mocker):
58 """Test detection of juju-lint version on Cli init."""
59 require_rvalue = MagicMock()
60 require_rvalue.version = version
61 mocker.patch.object(cli, "Config")
62
63 if version:
64 require_mock = mocker.patch.object(
65 cli.pkg_resources, "require", return_value=[require_rvalue]
66 )
67 else:
68 require_mock = mocker.patch.object(
69 cli.pkg_resources,
70 "require",
71 side_effect=cli.pkg_resources.DistributionNotFound,
72 )
73
74 expected_version = version or "unknown"
75
76 cli_instance = cli.Cli()
77
78 require_mock.assert_called_once_with("jujulint")
79 assert cli_instance.version == expected_version
80
81
82@pytest.mark.parametrize("rules_path", ["absolute", "relative", None])
83def test_cli_init_rules_path(rules_path, mocker):
84 """Test different methods of loading rules file on Cli init.
85
86 methods:
87 * via absolut path
88 * via path relative to config dir
89 * rules file could not be found
90 """
91 config_dir = "/tmp/foo"
92 file_path = "rules.yaml"
93 rule_file = MagicMock()
94 rule_file.get.return_value = file_path
95
96 config_dict = {
97 "logging": {"loglevel": MagicMock()},
98 "format": MagicMock(),
99 "rules": {"file": rule_file},
100 }
101 config = MagicMock()
102 config.__getitem__.side_effect = config_dict.__getitem__
103 config.config_dir.return_value = config_dir
104 mocker.patch.object(cli, "Config", return_value=config)
105 exit_mock = mocker.patch.object(cli.sys, "exit")
106
107 if rules_path == "absolute":
108 mocker.patch.object(cli.os.path, "isfile", return_value=True)
109 elif rules_path == "relative":
110 mocker.patch.object(cli.os.path, "isfile", side_effect=[False, True])
111 else:
112 mocker.patch.object(cli.os.path, "isfile", return_value=False)
113
114 cli_instance = cli.Cli()
115
116 if rules_path == "absolute":
117 assert cli_instance.lint_rules == file_path
118 exit_mock.assert_not_called()
119 elif rules_path == "relative":
120 assert cli_instance.lint_rules == "{}/{}".format(config_dir, file_path)
121 exit_mock.assert_not_called()
122 else:
123 exit_mock.assert_called_once_with(1)
124
125
126@pytest.mark.parametrize("is_cloud_set", [True, False])
127def test_cli_cloud_type(cli_instance, is_cloud_set):
128 """Test cloud_type() property of Cli class."""
129 cloud_type_value = "openstack"
130 cloud_type = MagicMock()
131 cloud_type.get.return_value = cloud_type_value
132
133 config = {"cloud-type": cloud_type} if is_cloud_set else {}
134 cli_instance.config = config
135
136 if is_cloud_set:
137 assert cli_instance.cloud_type == cloud_type_value
138 else:
139 assert cli_instance.cloud_type is None
140
141
142@pytest.mark.parametrize("is_file_set", [True, False])
143def test_cli_manual_file(cli_instance, is_file_set):
144 """Test manual_file() property of Cli class."""
145 manual_file_value = "./rules.yaml"
146 manual_file = MagicMock()
147 manual_file.get.return_value = manual_file_value
148
149 config = {"manual-file": manual_file} if is_file_set else {}
150 cli_instance.config = config
151
152 if is_file_set:
153 assert cli_instance.manual_file == manual_file_value
154 else:
155 assert cli_instance.manual_file is None
156
157
158@pytest.mark.parametrize(
159 "cloud_type_value, manual_file_value",
160 [
161 ("openstack", "rules.yaml"),
162 (None, None),
163 ],
164)
165def test_cli_startup_message(cli_instance, cloud_type_value, manual_file_value, mocker):
166 """Test output of a startup message."""
167 version = "1.0"
168 config_dir = "/tmp/"
169 lint_rules = "some rules"
170
171 log_level_value = "debug"
172 log_level = MagicMock()
173 log_level.get.return_value = log_level_value
174
175 cloud_type = MagicMock()
176 cloud_type.get.return_value = cloud_type_value
177
178 manual_file = MagicMock()
179 manual_file.get.return_value = manual_file_value
180
181 config_data = {
182 "logging": {"loglevel": log_level},
183 "cloud-type": cloud_type,
184 "manual-file": manual_file,
185 }
186
187 config = MagicMock()
188 config.config_dir.return_value = config_dir
189 config.__getitem__.side_effect = config_data.__getitem__
190 config.__contains__.side_effect = config_data.__contains__
191
192 expected_msg = (
193 "juju-lint version {} starting...\n\t* Config directory: {}\n"
194 "\t* Cloud type: {}\n\t* Manual file: {}\n\t* Rules file: {}\n"
195 "\t* Log level: {}\n"
196 ).format(
197 version,
198 config_dir,
199 cloud_type_value or "Unknown",
200 manual_file_value or False,
201 lint_rules,
202 log_level_value,
203 )
204
205 cli_instance.version = version
206 cli_instance.config = config
207 cli_instance.lint_rules = lint_rules
208 log_mock = mocker.patch.object(cli_instance, "logger")
209
210 assert cli_instance.cloud_type == cloud_type_value
211 cli_instance.startup_message()
212
213 log_mock.info.assert_called_once_with(expected_msg)
214
215
216def test_cli_usage(cli_instance):
217 """Test usage() method of Cli class."""
218 config_mock = MagicMock()
219 cli_instance.config = config_mock
220
221 cli_instance.usage()
222
223 config_mock.parser.print_help.assert_called_once()
224
225
226def test_cli_audit_file(cli_instance, mocker):
227 """Test method audit_file() from Cli class."""
228 filename = "/tmp/bundle.yaml"
229 rules = "/tmp/rules.yaml"
230 cloud_type = "openstack"
231 output_format = "text"
232 linter_object = MagicMock()
233
234 mock_linter = mocker.patch.object(cli, "Linter", return_value=linter_object)
235 cli_instance.lint_rules = rules
236 cli_instance.output_format = output_format
237
238 cli_instance.audit_file(filename, cloud_type)
239
240 mock_linter.assert_called_once_with(
241 filename, rules, cloud_type=cloud_type, output_format=output_format
242 )
243 linter_object.read_rules.assert_called_once()
244 linter_object.lint_yaml_file.assert_called_once_with(filename)
245
246
247def test_cli_audit_all(cli_instance, mocker):
248 """Test audit_all() method from Cli class."""
249 audit_mock = mocker.patch.object(cli_instance, "audit")
250 write_yaml_mock = mocker.patch.object(cli_instance, "write_yaml")
251
252 cloud_data = "cloud data"
253 clouds_value = ["cloud_1", "cloud_2"]
254 clouds = MagicMock()
255 clouds.get.return_value = clouds_value
256
257 config_data = {"clouds": clouds}
258 config = MagicMock()
259 config.__getitem__.side_effect = config_data.__getitem__
260
261 cli_instance.clouds = cloud_data
262 cli_instance.config = config
263
264 cli_instance.audit_all()
265
266 audit_mock.assert_has_calls([call(cloud) for cloud in clouds_value])
267 write_yaml_mock.assert_called_once_with(cloud_data, "all-data.yaml")
268
269
270@pytest.mark.parametrize("success", [True, False])
271def test_cli_audit(cli_instance, success, mocker):
272 """Test audit() method from Cli class."""
273 cloud_name = "test cloud"
274 lint_rules = "rules.yaml"
275 cloud_data = {
276 "access": "ssh",
277 "sudo": "root",
278 "host": "juju.host",
279 "type": "openstack",
280 }
281
282 cloud = MagicMock()
283 cloud.get.return_value = cloud_data
284
285 config_data = {"clouds": {cloud_name: cloud}}
286
287 cloud_state = {"key": "value"}
288 mock_openstack_instance = MagicMock()
289 mock_openstack_instance.refresh.return_value = success
290 mock_openstack_instance.cloud_state = cloud_state
291 mock_openstack = mocker.patch.object(
292 cli, "OpenStack", return_value=mock_openstack_instance
293 )
294
295 mock_yaml = mocker.patch.object(cli_instance, "write_yaml")
296
297 mock_logger = MagicMock()
298 cli_instance.logger = mock_logger
299
300 cli_instance.config = config_data
301 cli_instance.lint_rules = lint_rules
302
303 # assert cli_instance.config["clouds"]
304 cli_instance.audit(cloud_name=cloud_name)
305
306 mock_openstack.assert_called_once_with(
307 cloud_name,
308 access_method=cloud_data["access"],
309 ssh_host=cloud_data["host"],
310 sudo_user=cloud_data["sudo"],
311 lint_rules=lint_rules,
312 )
313
314 if success:
315 assert cli_instance.clouds[cloud_name] == cloud_state
316 mock_yaml.assert_called_once_with(
317 cloud_state, "{}-state.yaml".format(cloud_name)
318 )
319 mock_openstack_instance.audit.assert_called_once()
320 else:
321 mock_logger.error.assert_called_once_with(
322 "[{}] Failed getting cloud state".format(cloud_name)
323 )
324
325
326def test_cli_write_yaml(cli_instance, mocker):
327 """Test write_yaml() method from Cli class."""
328 yaml_mock = mocker.patch.object(cli, "yaml")
329 data = "{'yaml': 'data'}"
330 file_name = "dump.yaml"
331
332 output_folder_value = "/tmp"
333 output_folder = MagicMock()
334 output_folder.get.return_value = output_folder_value
335
336 opened_file = MagicMock()
337 mock_open = mocker.patch("builtins.open", return_value=opened_file)
338
339 config = {"output": {"dump": True, "folder": output_folder}}
340
341 cli_instance.config = config
342 cli_instance.write_yaml(data, file_name)
343
344 mock_open.assert_called_once_with(
345 "{}/{}".format(output_folder_value, file_name), "w"
346 )
347 yaml_mock.dump.assert_called_once_with(data, opened_file)
348
349
350@pytest.mark.parametrize("audit_type", ["file", "all", None])
351def test_main(cli_instance, audit_type, mocker):
352 """Test main entrypoint of jujulint."""
353 mocker.patch.object(cli_instance, "startup_message")
354 mocker.patch.object(cli_instance, "usage")
355 mocker.patch.object(cli_instance, "audit_file")
356 mocker.patch.object(cli_instance, "audit_all")
357
358 manual_file_value = "bundle.yaml"
359 manual_file = MagicMock()
360 manual_file.get.return_value = manual_file_value
361
362 cloud_type_value = "openstack"
363 cloud_type = MagicMock()
364 cloud_type.get.return_value = cloud_type_value
365
366 cli_instance.config = {"cloud-type": cloud_type}
367
368 if audit_type == "file":
369 cli_instance.config["manual-file"] = manual_file
370 elif audit_type == "all":
371 cli_instance.config["clouds"] = ["cloud_1", "cloud_2"]
372
373 mocker.patch.object(cli, "Cli", return_value=cli_instance)
374
375 cli.main()
376
377 if audit_type == "file":
378 cli_instance.audit_file.assert_called_once_with(
379 manual_file_value, cloud_type=cloud_type_value
380 )
381 cli_instance.audit_all.assert_not_called()
382 elif audit_type == "all":
383 cli_instance.audit_all.assert_called_once()
384 cli_instance.audit_file.assert_not_called()
385 else:
386 cli_instance.usage.assert_called_once()
387 cli_instance.audit_all.assert_not_called()
388 cli_instance.audit_file.assert_not_called()
diff --git a/tests/test_cloud.py b/tests/test_cloud.py
index 885f319..a3ba423 100644
--- a/tests/test_cloud.py
+++ b/tests/test_cloud.py
@@ -1,129 +1,512 @@
1#!/usr/bin/python31#!/usr/bin/python3
2"""Tests for Cloud."""2"""Tests for cloud.py module."""
3from subprocess import CalledProcessError3from subprocess import CalledProcessError
4from unittest.mock import call, patch4from unittest.mock import MagicMock, call, patch
55
6import pytest
67
7class TestCloud:8from jujulint import cloud
8 """Test the main Cloud class."""
99
10 @patch("jujulint.cloud.check_output")
11 def test_get_bundle_no_apps(self, mock_check_out, cloud):
12 """Models with no apps raises CalledProcessError to export bundle."""
13 cmd = ["juju", "export-bundle", "-m", "my_controller:controller"]
14 e = CalledProcessError(1, cmd)
15 mock_check_out.side_effect = e
16 cloud.get_juju_bundle("my_controller", "controller")
17 expected_error_msg = call.error(e)
1810
19 expected_warn_msg = call.warn(11@patch("jujulint.cloud.check_output")
20 (12def test_get_bundle_no_apps(mock_check_out, cloud_instance):
21 "An error happened to get the bundle on my_controller:controller. "13 """Models with no apps raises CalledProcessError to export bundle."""
22 "If the model doesn't have apps, disconsider this message."14 cmd = ["juju", "export-bundle", "-m", "my_controller:controller"]
23 )15 e = CalledProcessError(1, cmd)
16 mock_check_out.side_effect = e
17 cloud_instance.get_juju_bundle("my_controller", "controller")
18 expected_error_msg = call.error(e)
19
20 expected_warn_msg = call.warn(
21 (
22 "An error happened to get the bundle on my_controller:controller. "
23 "If the model doesn't have apps, disconsider this message."
24 )24 )
25 assert expected_error_msg in cloud.logger.method_calls25 )
26 assert expected_warn_msg in cloud.logger.method_calls26 assert expected_error_msg in cloud_instance.logger.method_calls
2727 assert expected_warn_msg in cloud_instance.logger.method_calls
28 @patch("jujulint.cloud.Cloud.parse_yaml")28
29 @patch("jujulint.cloud.Cloud.run_command")29
30 def test_get_bundle_offer_side(30@patch("jujulint.cloud.Cloud.parse_yaml")
31 self, mock_run, mock_parse, cloud, juju_export_bundle31@patch("jujulint.cloud.Cloud.run_command")
32 ):32def test_get_bundle_offer_side(
33 """Test the bundle generated in the offer side."""33 mock_run, mock_parse, cloud_instance, juju_export_bundle
34 # simulate cloud_state with info that came from "get_juju_status"34):
35 cloud.cloud_state = {35 """Test the bundle generated in the offer side."""
36 "my_controller": {36 # simulate cloud_state with info that came from "get_juju_status"
37 "models": {37 cloud_instance.cloud_state = {
38 "my_model_1": {38 "my_controller": {
39 "applications": {39 "models": {
40 "nrpe": {40 "my_model_1": {
41 "charm-origin": "charmhub",41 "applications": {
42 "os": "ubuntu",42 "nrpe": {
43 "endpoint-bindings": {"": "alpha", "monitors": "alpha"},43 "charm-origin": "charmhub",
44 }44 "os": "ubuntu",
45 "endpoint-bindings": {"": "alpha", "monitors": "alpha"},
45 }46 }
46 },47 }
47 "my_model_2": {},48 },
48 }49 "my_model_2": {},
49 }50 }
50 }51 }
51 mock_parse.return_value = juju_export_bundle["my_model_1"]52 }
52 # "offers" field exists inside nrpe because of the overlay bundle.53 mock_parse.return_value = juju_export_bundle["my_model_1"]
53 # saas field doesn't exist in the offer side because there is no url.54 # "offers" field exists inside nrpe because of the overlay bundle.
54 # don't overwrite information that came from "get_juju_status".55 # saas field doesn't exist in the offer side because there is no url.
55 expected_cloud_state = {56 # don't overwrite information that came from "get_juju_status".
56 "my_controller": {57 expected_cloud_state = {
57 "models": {58 "my_controller": {
58 "my_model_1": {59 "models": {
59 "applications": {60 "my_model_1": {
60 "nrpe": {61 "applications": {
61 "charm": "nrpe",62 "nrpe": {
62 "charm-origin": "charmhub",63 "charm": "nrpe",
63 "os": "ubuntu",64 "charm-origin": "charmhub",
64 "endpoint-bindings": {"": "alpha", "monitors": "alpha"},65 "os": "ubuntu",
65 "channel": "stable",66 "endpoint-bindings": {"": "alpha", "monitors": "alpha"},
66 "revision": 86,67 "channel": "stable",
67 "offers": {68 "revision": 86,
68 "nrpe": {69 "offers": {
69 "endpoints": ["monitors"],70 "nrpe": {
70 "acl": {"admin": "admin"},71 "endpoints": ["monitors"],
71 }72 "acl": {"admin": "admin"},
72 },73 }
73 },
74 "ubuntu": {
75 "charm": "ubuntu",
76 "channel": "stable",
77 "revision": 19,
78 "num_units": 1,
79 "to": ["0"],
80 "constraints": "arch=amd64",
81 },74 },
82 }
83 },
84 "my_model_2": {},
85 }
86 }
87 }
88 cloud.get_juju_bundle("my_controller", "my_model_1")
89 assert mock_run.called_once_with(
90 "juju export-bundle -m my_controller:my_model_1"
91 )
92 assert cloud.cloud_state == expected_cloud_state
93
94 @patch("jujulint.cloud.Cloud.parse_yaml")
95 @patch("jujulint.cloud.Cloud.run_command")
96 def test_get_bundle_consumer_side(
97 self, mock_run, mock_parse, cloud, juju_export_bundle
98 ):
99 """Test the bundle generated in the consumer side."""
100 mock_parse.return_value = juju_export_bundle["my_model_2"]
101 # "offers" field won't exist in the consumer side
102 # saas field exists because the consumer side shows url
103 expected_cloud_state = {
104 "my_controller": {
105 "models": {
106 "my_model_1": {},
107 "my_model_2": {
108 "applications": {
109 "nagios": {
110 "charm": "nagios",
111 "channel": "stable",
112 "revision": 49,
113 "num_units": 1,
114 "to": ["0"],
115 "constraints": "arch=amd64",
116 }
117 },75 },
118 "saas": {76 "ubuntu": {
119 "nrpe": {"url": "my_controller:admin/my_model_1.nrpe"}77 "charm": "ubuntu",
78 "channel": "stable",
79 "revision": 19,
80 "num_units": 1,
81 "to": ["0"],
82 "constraints": "arch=amd64",
120 },83 },
84 }
85 },
86 "my_model_2": {},
87 }
88 }
89 }
90 cloud_instance.get_juju_bundle("my_controller", "my_model_1")
91 assert mock_run.called_once_with("juju export-bundle -m my_controller:my_model_1")
92 assert cloud_instance.cloud_state == expected_cloud_state
93
94
95@patch("jujulint.cloud.Cloud.parse_yaml")
96@patch("jujulint.cloud.Cloud.run_command")
97def test_get_bundle_consumer_side(
98 mock_run, mock_parse, cloud_instance, juju_export_bundle
99):
100 """Test the bundle generated in the consumer side."""
101 mock_parse.return_value = juju_export_bundle["my_model_2"]
102 # "offers" field won't exist in the consumer side
103 # saas field exists because the consumer side shows url
104 expected_cloud_state = {
105 "my_controller": {
106 "models": {
107 "my_model_1": {},
108 "my_model_2": {
109 "applications": {
110 "nagios": {
111 "charm": "nagios",
112 "channel": "stable",
113 "revision": 49,
114 "num_units": 1,
115 "to": ["0"],
116 "constraints": "arch=amd64",
117 }
121 },118 },
122 }119 "saas": {"nrpe": {"url": "my_controller:admin/my_model_1.nrpe"}},
120 },
123 }121 }
124 }122 }
125 cloud.get_juju_bundle("my_controller", "my_model_2")123 }
126 assert mock_run.called_once_with(124 cloud_instance.get_juju_bundle("my_controller", "my_model_2")
127 "juju export-bundle -m my_controller:my_model_2"125 assert mock_run.called_once_with("juju export-bundle -m my_controller:my_model_2")
126 assert cloud_instance.cloud_state == expected_cloud_state
127
128
129@pytest.mark.parametrize(
130 "sudo_user, access_method, ssh_host",
131 [("", "local", ""), ("root", "ssh", "juju.host")],
132)
133def test_cloud_init(sudo_user, access_method, ssh_host, mocker):
134 """Test Cloud class initialization."""
135 logger_mock = MagicMock()
136 mocker.patch.object(cloud, "Logger", return_value=logger_mock)
137 connection_mock = MagicMock()
138 mocker.patch.object(cloud, "Connection", return_value=connection_mock)
139 local_fqdn = "localhost"
140 mocker.patch.object(cloud.socket, "getfqdn", return_value=local_fqdn)
141
142 name = "Foo Cloud"
143 lint_rules = {"Custom": "ruleset"}
144 lint_overrides = {"Override": "rule"}
145 cloud_type = "test"
146
147 cloud_instance = cloud.Cloud(
148 name=name,
149 lint_rules=lint_rules,
150 access_method=access_method,
151 ssh_host=ssh_host,
152 sudo_user=sudo_user,
153 lint_overrides=lint_overrides,
154 cloud_type=cloud_type,
155 )
156
157 assert cloud_instance.cloud_state == {}
158 assert cloud_instance.access_method == access_method
159 assert cloud_instance.sudo_user == sudo_user or ""
160 assert cloud_instance.lint_rules == lint_rules
161 assert cloud_instance.lint_overrides == lint_overrides
162
163 if sudo_user:
164 assert cloud_instance.fabric_config.get("sudo") == {"user": sudo_user}
165 else:
166 assert cloud_instance.fabric_config == {}
167
168 if access_method == "local":
169 assert cloud_instance.hostname == local_fqdn
170 connection_mock.assert_not_called()
171 else:
172 assert cloud_instance.hostname == ssh_host
173 assert cloud_instance.connection == connection_mock
174
175
176def test_run_local_command(patch_cloud_init, mocker):
177 """Test running command when the cloud access method is 'local'."""
178 expected_result = ".\n.."
179 check_output_mock = mocker.patch.object(
180 cloud, "check_output", return_value=expected_result
181 )
182
183 command = "ls -la"
184 command_split = command.split()
185
186 cloud_instance = cloud.Cloud(
187 name="local test cloud", access_method="local", cloud_type="test"
188 )
189 result = cloud_instance.run_command(command)
190
191 # command is executed locally
192 check_output_mock.assert_called_once_with(command_split)
193 assert result == expected_result
194
195
196@pytest.mark.parametrize("sudo", [True, False])
197def test_run_remote_command(patch_cloud_init, sudo, mocker):
198 """Test running command when the cloud access method is 'ssh'.
199
200 This test has two variants. Executing commands as regular user
201 and executing them as root.
202 """
203 expected_result = MagicMock()
204 expected_result.stdout = ".\n.."
205
206 executor_method = "sudo" if sudo else "run"
207
208 command = "ls -la"
209
210 cloud_instance = cloud.Cloud(
211 name="remote test cloud",
212 access_method="ssh",
213 ssh_host="juju.host",
214 sudo_user="root" if sudo else "",
215 cloud_type="test",
216 )
217
218 mocker.patch.object(
219 cloud_instance.connection, executor_method, return_value=expected_result
220 )
221
222 result = cloud_instance.run_command(command)
223
224 # command was executed remotely as root
225 if sudo:
226 cloud_instance.connection.sudo.assert_called_once_with(
227 command, hide=True, warn=True
228 )
229 else:
230 cloud_instance.connection.run.assert_called_once_with(
231 command, hide=True, warn=True
232 )
233 assert result == expected_result.stdout
234
235
236@pytest.mark.parametrize("sudo", [True, False])
237def test_run_remote_command_fail(patch_cloud_init, sudo, mocker):
238 """Test error logging when remote command fails.
239
240 This test has two variants. Executing commands as regular user
241 and executing them as root.
242 """
243 command = "ls -la"
244 cloud_name = "remote test cloud"
245 executor_method = "sudo" if sudo else "run"
246 exception = cloud.SSHException()
247 expected_message = "[{}] SSH command {} failed: {}".format(
248 cloud_name, command, exception
249 )
250
251 cloud_instance = cloud.Cloud(
252 name=cloud_name,
253 access_method="ssh",
254 ssh_host="juju.host",
255 sudo_user="root" if sudo else "",
256 cloud_type="test",
257 )
258
259 mocker.patch.object(
260 cloud_instance.connection, executor_method, side_effect=exception
261 )
262
263 # reset logger mock to wipe any previous calls
264 cloud_instance.logger.reset_mock()
265
266 result = cloud_instance.run_command(command)
267
268 # remote command failure was logged
269 cloud_instance.logger.error.assert_called_once_with(expected_message)
270 assert result is None
271
272
273def test_yaml_loading():
274 """Test loading yaml documents into list of dictionaries."""
275 yaml_string = "controllers:\n" " ctrl1:\n" " current-model: test-model\n"
276 expected_output = [{"controllers": {"ctrl1": {"current-model": "test-model"}}}]
277
278 assert cloud.Cloud.parse_yaml(yaml_string) == expected_output
279
280
281@pytest.mark.parametrize("success", [True, False])
282def test_get_juju_controllers(patch_cloud_init, success, mocker):
283 """Test method that retrieves a list of juju controllers.
284
285 This test also verifies behavior in case the command to fetch controllers fails.
286 """
287 controller_name = "foo"
288 controller_config = {
289 "current-model": "default",
290 "user": "admin",
291 "access": "superuser",
292 }
293 controller_list = [{"controllers": {controller_name: controller_config}}]
294
295 mocker.patch.object(cloud.Cloud, "run_command", return_value=success)
296 mocker.patch.object(cloud.Cloud, "parse_yaml", return_value=controller_list)
297
298 cloud_instance = cloud.Cloud(name="Test cloud")
299
300 result = cloud_instance.get_juju_controllers()
301
302 if success:
303 assert result
304 assert (
305 cloud_instance.cloud_state[controller_name]["config"] == controller_config
306 )
307 else:
308 assert not result
309 assert controller_name not in cloud_instance.cloud_state
310 cloud_instance.logger.error.assert_called_once_with(
311 "[{}] Could not get controller list".format(cloud_instance.name)
312 )
313
314
315@pytest.mark.parametrize("success", [True, False])
316def test_get_juju_models(patch_cloud_init, success, mocker):
317 """Test methods that retrieves a list of juju models."""
318 model_foo = {"name": "admin/foo", "short-name": "foo", "uuid": "129bd0f0"}
319 model_bar = {"name": "admin/bar", "short-name": "bar", "uuid": "b513b5e3"}
320 model_list = [{"models": [model_foo, model_bar]}] if success else []
321
322 controller_name = "controller_1"
323 controllers = {controller_name: {}} if success else {}
324
325 run_cmd_mock = mocker.patch.object(cloud.Cloud, "run_command", return_value=success)
326 mocker.patch.object(cloud.Cloud, "parse_yaml", return_value=model_list)
327 mocker.patch.object(cloud.Cloud, "get_juju_controllers", return_value=controllers)
328
329 cloud_instance = cloud.Cloud(name="Test cloud")
330 cloud_instance.cloud_state = controllers
331
332 result = cloud_instance.get_juju_models()
333
334 if success:
335 assert result
336 run_cmd_mock.assert_called_once_with(
337 "juju models -c {} --format yaml".format(controller_name)
128 )338 )
129 assert cloud.cloud_state == expected_cloud_state339 for model in [model_foo, model_bar]:
340 model_name = model["short-name"]
341 model_config = cloud_instance.cloud_state[controller_name]["models"][
342 model_name
343 ]["config"]
344 assert model_config == model
345 else:
346 assert not result
347 for model in [model_foo, model_bar]:
348 model_name = model["short-name"]
349 assert model_name not in cloud_instance.cloud_state.get(
350 controller_name, {}
351 ).get("models", {})
352
353
354@pytest.mark.parametrize("success", [True, False])
355def test_get_juju_state(cloud_instance, success, mocker):
356 """Test function "get_juju_state" that updates local juju state."""
357 controller_foo = {
358 "models": {"foo_1": "foo_1_model_data", "foo_2": "foo_2_model_data"}
359 }
360 controller_bar = {
361 "models": {"bar_1": "bar_1_model_data", "bar_2": "bar_2_model_data"}
362 }
363 cloud_state = {"controller_foo": controller_foo, "controller_bar": controller_bar}
364
365 expected_calls = [
366 call("controller_foo", "foo_1"),
367 call("controller_foo", "foo_2"),
368 call("controller_bar", "bar_1"),
369 call("controller_bar", "bar_2"),
370 ]
371
372 mocker.patch.object(cloud_instance, "get_juju_models", return_value=success)
373 get_status_mock = mocker.patch.object(cloud_instance, "get_juju_status")
374 get_bundle_mock = mocker.patch.object(cloud_instance, "get_juju_bundle")
375
376 cloud_instance.cloud_state = cloud_state
377
378 result = cloud_instance.get_juju_state()
379
380 if success:
381 assert result
382 get_status_mock.assert_has_calls(expected_calls)
383 get_bundle_mock.assert_has_calls(expected_calls)
384 else:
385 assert not result
386 get_status_mock.assert_not_called()
387 get_bundle_mock.assert_not_called()
388
389
390def test_get_juju_status(cloud_instance, mocker):
391 """Test updating status of a selected model."""
392 model_version = "1"
393 model_name = "foo model"
394 controller_name = "foo controller"
395 machine_1_implicit_name = "1"
396 machine_2_implicit_name = "2"
397 machine_2_explicit_name = "explicit_machine - 2"
398 cmd_output_mock = "foo data"
399 model_status = {
400 "model": {"version": model_version},
401 "machines": {
402 machine_1_implicit_name: {"arch": "x86"},
403 machine_2_implicit_name: {
404 "arch": "armv7",
405 "display-name": machine_2_explicit_name,
406 },
407 },
408 "applications": {
409 "ubuntu": {"application-status": {"current": "ready"}},
410 "ntp": {"application-status": {"current": "waiting"}},
411 },
412 }
413
414 run_cmd_mock = mocker.patch.object(
415 cloud_instance, "run_command", return_value=cmd_output_mock
416 )
417 mocker.patch.object(cloud_instance, "parse_yaml", return_value=[model_status])
418
419 cloud_instance.cloud_state = {controller_name: {"models": {model_name: {}}}}
420
421 cloud_instance.get_juju_status(controller_name, model_name)
422
423 # assert that correct command was called
424 run_cmd_mock.assert_called_with(
425 "juju status -m {}:{} --format yaml".format(controller_name, model_name)
426 )
427
428 model_data = cloud_instance.cloud_state[controller_name]["models"][model_name]
429 # assert that application data was loaded to the model
430 assert model_data["applications"] == model_status["applications"]
431 # assert that both implicitly and explicitly named machines are loaded in the model
432 expected_machines = {
433 machine_1_implicit_name: {"arch": "x86", "machine_id": machine_1_implicit_name},
434 machine_2_explicit_name: {
435 "arch": "armv7",
436 "display-name": machine_2_explicit_name,
437 "machine_id": machine_2_implicit_name,
438 },
439 }
440 assert model_data["machines"] == expected_machines
441
442
443def test_refresh(cloud_instance, mocker):
444 """Test refresh method."""
445 expected_result = True
446 get_state_mock = mocker.patch.object(
447 cloud_instance, "get_juju_state", return_value=expected_result
448 )
449
450 result = cloud_instance.refresh()
451
452 get_state_mock.assert_called_once()
453 assert result == expected_result
454
455
456def test_audit(patch_cloud_init, mocker):
457 """Test execution of Audits for all models in cloud."""
458 cloud_name = "Test Cloud"
459 lint_rules = "some lint rules"
460 override_rules = "some overrides"
461 cloud_type = "openstack"
462 cloud_state = {
463 "controller_foo": {
464 "models": {
465 "model_foo_1": {"name": "foo_1"},
466 "model_foo_2": {"name": "foo_2"},
467 }
468 },
469 "controller_bar": {
470 "models": {
471 "model_bar_1": {"name": "bar_1"},
472 "model_bar_2": {"name": "bar_2"},
473 }
474 },
475 }
476 expected_linter_init_calls = []
477 expected_do_lint_calls = []
478 expected_read_rules_calls = []
479 for controller, controller_data in cloud_state.items():
480 for model, model_data in controller_data["models"].items():
481 expected_linter_init_calls.append(
482 call(
483 cloud_name,
484 lint_rules,
485 overrides=override_rules,
486 cloud_type=cloud_type,
487 controller_name=controller,
488 model_name=model,
489 )
490 )
491 expected_do_lint_calls.append(call(model_data))
492 expected_read_rules_calls.append(call())
493
494 linter_object_mock = MagicMock()
495
496 linter_class_mock = mocker.patch.object(
497 cloud, "Linter", return_value=linter_object_mock
498 )
499
500 cloud_instance = cloud.Cloud(
501 name=cloud_name,
502 lint_rules=lint_rules,
503 lint_overrides=override_rules,
504 cloud_type=cloud_type,
505 )
506 cloud_instance.cloud_state = cloud_state
507
508 cloud_instance.audit()
509
510 linter_class_mock.assert_has_calls(expected_linter_init_calls)
511 linter_object_mock.read_rules.assert_has_calls(expected_read_rules_calls)
512 linter_object_mock.do_lint.assert_has_calls(expected_do_lint_calls)
diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
index 6e40b74..6c611f6 100644
--- a/tests/test_jujulint.py
+++ b/tests/test_jujulint.py
@@ -6,7 +6,7 @@ from unittest import mock
66
7import pytest7import pytest
88
9from jujulint import check_spaces9from jujulint import check_spaces, lint
1010
1111
12class TestUtils:12class TestUtils:
@@ -72,6 +72,31 @@ class TestLinter:
72 linter.do_lint(juju_status)72 linter.do_lint(juju_status)
73 assert len(linter.output_collector["errors"]) == 073 assert len(linter.output_collector["errors"]) == 0
7474
75 def test_minimal_rules_without_subordinates(self, linter, juju_status):
76 """Process rules with none of the applications having subordinate charms."""
77 juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"].pop("subordinates")
78 juju_status["applications"].pop("ntp")
79 linter.lint_rules["subordinates"] = {}
80
81 linter.do_lint(juju_status)
82 assert len(linter.output_collector["errors"]) == 0
83
84 def test_minimal_rules_json_output(self, linter, juju_status, mocker):
85 """Process rules and print output in json format."""
86 expected_output = "{result: dict}"
87 json_mock = mocker.patch.object(
88 lint.json, "dumps", return_value=expected_output
89 )
90 print_mock = mocker.patch("builtins.print")
91
92 linter.output_format = "json"
93 linter.do_lint(juju_status)
94
95 json_mock.assert_called_once_with(
96 linter.output_collector, indent=2, sort_keys=True
97 )
98 print_mock.assert_called_once_with(expected_output)
99
75 def test_charm_identification(self, linter, juju_status):100 def test_charm_identification(self, linter, juju_status):
76 """Test that applications are mapped to charms."""101 """Test that applications are mapped to charms."""
77 juju_status["applications"]["ubuntu2"] = {102 juju_status["applications"]["ubuntu2"] = {
@@ -206,6 +231,19 @@ class TestLinter:
206 assert errors[0]["id"] == "AZ-invalid-number"231 assert errors[0]["id"] == "AZ-invalid-number"
207 assert errors[0]["num_azs"] == 2232 assert errors[0]["num_azs"] == 2
208233
234 def test_az_missing(self, linter, juju_status, mocker):
235 """Test that AZ parsing logs warning if AZ is not found."""
236 # duplicate a AZ name so we have 2 AZs instead of the expected 3
237 juju_status["machines"]["2"]["hardware"] = ""
238 expected_msg = (
239 "Machine 2 has no availability-zone info in hardware field; skipping."
240 )
241 logger_mock = mocker.patch.object(linter, "_log_with_header")
242
243 linter.do_lint(juju_status)
244
245 logger_mock.assert_any_call(expected_msg, level=logging.WARN)
246
209 def test_az_balancing(self, linter, juju_status):247 def test_az_balancing(self, linter, juju_status):
210 """Test that applications are balanced across AZs."""248 """Test that applications are balanced across AZs."""
211 # add an extra machine in an existing AZ249 # add an extra machine in an existing AZ
@@ -614,8 +652,8 @@ applications:
614 assert errors[0]["expected_value"] == 3652 assert errors[0]["expected_value"] == 3
615 assert errors[0]["actual_value"] == 0653 assert errors[0]["actual_value"] == 0
616654
617 def test_config_isset_false(self, linter, juju_status):655 def test_config_isset_false_fail(self, linter, juju_status):
618 """Test the config condition 'isset' false."""656 """Test error handling if config condition 'isset'=false is not met."""
619 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": False}}}657 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": False}}}
620 juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": 0}658 juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": 0}
621 linter.do_lint(juju_status)659 linter.do_lint(juju_status)
@@ -627,8 +665,17 @@ applications:
627 assert errors[0]["rule"] == "fake-opt"665 assert errors[0]["rule"] == "fake-opt"
628 assert errors[0]["actual_value"] == 0666 assert errors[0]["actual_value"] == 0
629667
630 def test_config_isset_true(self, linter, juju_status):668 def test_config_isset_false_pass(self, linter, juju_status):
631 """Test the config condition 'isset' true."""669 """Test handling if config condition 'isset'=false is met."""
670 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": False}}}
671 juju_status["applications"]["ubuntu"]["options"] = {}
672 linter.do_lint(juju_status)
673
674 errors = linter.output_collector["errors"]
675 assert len(errors) == 0
676
677 def test_config_isset_true_fail(self, linter, juju_status):
678 """Test error handling if config condition 'isset'=true is not met."""
632 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": True}}}679 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": True}}}
633 juju_status["applications"]["ubuntu"]["options"] = {}680 juju_status["applications"]["ubuntu"]["options"] = {}
634 linter.do_lint(juju_status)681 linter.do_lint(juju_status)
@@ -639,6 +686,15 @@ applications:
639 assert errors[0]["application"] == "ubuntu"686 assert errors[0]["application"] == "ubuntu"
640 assert errors[0]["rule"] == "fake-opt"687 assert errors[0]["rule"] == "fake-opt"
641688
689 def test_config_isset_true_pass(self, linter, juju_status):
690 """Test handling if config condition 'isset'=true is met."""
691 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": True}}}
692 juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": 0}
693 linter.do_lint(juju_status)
694
695 errors = linter.output_collector["errors"]
696 assert len(errors) == 0
697
642 def test_config_search_valid(self, linter, juju_status):698 def test_config_search_valid(self, linter, juju_status):
643 """Test the config condition 'search' when valid."""699 """Test the config condition 'search' when valid."""
644 linter.lint_rules["config"] = {700 linter.lint_rules["config"] = {
@@ -670,6 +726,62 @@ applications:
670 assert errors[0]["expected_value"] == "\\W\\*, \\W\\*, 25000, 27500"726 assert errors[0]["expected_value"] == "\\W\\*, \\W\\*, 25000, 27500"
671 assert errors[0]["actual_value"] == "[[/, queue1, 10, 20], [\\*, \\*, 10, 20]]"727 assert errors[0]["actual_value"] == "[[/, queue1, 10, 20], [\\*, \\*, 10, 20]]"
672728
729 def test_config_search_missing(self, linter, mocker):
730 """Test the config search method logs warning if the config option is missing."""
731 app_name = "ubuntu"
732 check_value = 0
733 config_key = "missing-opt"
734 app_config = {}
735 expected_log = (
736 "Application {} has no config for '{}', can't search the regex pattern "
737 "{}."
738 ).format(app_name, config_key, repr(check_value))
739
740 logger_mock = mocker.patch.object(linter, "_log_with_header")
741
742 result = linter.search(app_name, check_value, config_key, app_config)
743
744 assert result is False
745 logger_mock.assert_called_once_with(expected_log, level=logging.WARN)
746
747 def test_check_config_generic_missing_option(self, linter, mocker):
748 """Test behavior of check_config_generic() when config option is missing."""
749 operator_ = lint.ConfigOperator(
750 name="eq", repr="==", check=None, error_template=""
751 )
752 app_name = "ubuntu"
753 check_value = 0
754 config_key = "missing-opt"
755 app_config = {}
756 expected_log = (
757 "Application {} has no config for '{}', cannot determine if {} " "{}."
758 ).format(app_name, config_key, operator_.repr, repr(check_value))
759
760 logger_mock = mocker.patch.object(linter, "_log_with_header")
761
762 result = linter.check_config_generic(
763 operator_, app_name, check_value, config_key, app_config
764 )
765
766 logger_mock.assert_called_once_with(expected_log, level=logging.WARN)
767 assert result is False
768
769 def test_check_config_unknown_check_operator(self, linter, mocker):
770 """Test that warning is logged when unknown check operator is encountered."""
771 app_name = "ubuntu"
772 config = {}
773 bad_rule = "bad_rule"
774 bad_check = "bad_check"
775 rules = {bad_rule: {bad_check: 0}}
776 expected_log = (
777 "Application {} has unknown check operation for {}: " "{}."
778 ).format(app_name, bad_rule, bad_check)
779
780 logger_mock = mocker.patch.object(linter, "_log_with_header")
781
782 linter.check_config(app_name, config, rules)
783 logger_mock.assert_any_call(expected_log, level=logging.WARN)
784
673 def test_parse_cmr_apps_export_bundle(self, linter):785 def test_parse_cmr_apps_export_bundle(self, linter):
674 """Test the charm CMR parsing for bundles."""786 """Test the charm CMR parsing for bundles."""
675 parsed_yaml = {787 parsed_yaml = {
@@ -703,6 +815,16 @@ applications:
703 linter.parse_cmr_apps(parsed_yaml)815 linter.parse_cmr_apps(parsed_yaml)
704 assert linter.model.cmr_apps == {"grafana", "nagios"}816 assert linter.model.cmr_apps == {"grafana", "nagios"}
705817
818 def test_parse_cmr_apps_graylog(self, linter):
819 """Test the charm CMR parsing adds elasticsearch dependency if graylog is present."""
820 parsed_yaml = {
821 "saas": {
822 "graylog": {"url": "foundations-maas:admin/lma.graylog"},
823 }
824 }
825 linter.parse_cmr_apps(parsed_yaml)
826 assert linter.model.cmr_apps == {"graylog", "elasticsearch"}
827
706 def test_check_charms_ops_mandatory_crm_success(self, linter):828 def test_check_charms_ops_mandatory_crm_success(self, linter):
707 """829 """
708 Test the logic for checking ops mandatory charms provided via CMR.830 Test the logic for checking ops mandatory charms provided via CMR.
@@ -748,8 +870,9 @@ applications:
748 rules_path.write_text('---\nkey:\n "value"')870 rules_path.write_text('---\nkey:\n "value"')
749871
750 linter.filename = str(rules_path)872 linter.filename = str(rules_path)
751 linter.read_rules()873 result = linter.read_rules()
752 assert linter.lint_rules == {"key": "value"}874 assert linter.lint_rules == {"key": "value"}
875 assert result
753876
754 def test_read_rules_include(self, linter, tmp_path):877 def test_read_rules_include(self, linter, tmp_path):
755 """Test that rules YAML with an include is imported as expected."""878 """Test that rules YAML with an include is imported as expected."""
@@ -760,8 +883,41 @@ applications:
760 rules_path.write_text('---\n!include include.yaml\nkey:\n "value"')883 rules_path.write_text('---\n!include include.yaml\nkey:\n "value"')
761884
762 linter.filename = str(rules_path)885 linter.filename = str(rules_path)
763 linter.read_rules()886 result = linter.read_rules()
764 assert linter.lint_rules == {"key": "value", "key-inc": "value2"}887 assert linter.lint_rules == {"key": "value", "key-inc": "value2"}
888 assert result
889
890 def test_read_rules_overrides(self, linter, tmp_path):
891 """Test application of override values to the rules."""
892 rules_path = tmp_path / "rules.yaml"
893 rules_path.write_text('---\nkey:\n "value"\nsubordinates: {}')
894
895 linter.overrides = "override_1:value_1#override_2:value_2"
896
897 linter.filename = str(rules_path)
898 linter.read_rules()
899 assert linter.lint_rules == {
900 "key": "value",
901 "subordinates": {
902 "override_1": {"where": "value_1"},
903 "override_2": {"where": "value_2"},
904 },
905 }
906
907 def test_read_rules_fail(self, linter, mocker):
908 """Test handling of a read_rules() failure."""
909 rule_file = "rules.yaml"
910 mocker.patch.object(lint.os.path, "isfile", return_value=False)
911 logger_mock = mock.MagicMock()
912 linter.logger = logger_mock
913 linter.filename = rule_file
914
915 result = linter.read_rules()
916
917 assert not result
918 logger_mock.error.assert_called_once_with(
919 "Rules file {} does not exist.".format(rule_file)
920 )
765921
766 check_spaces_example_bundle = {922 check_spaces_example_bundle = {
767 "applications": {923 "applications": {
@@ -993,3 +1149,65 @@ applications:
9931149
994 linter.check_spaces(bundle)1150 linter.check_spaces(bundle)
995 assert logger_mock.warning.call_args_list == expected_warning_callings1151 assert logger_mock.warning.call_args_list == expected_warning_callings
1152
1153 def test_check_spaces_exception_handling(self, linter, mocker):
1154 """Test exception handling during check_spaces() method."""
1155 logger_mock = mock.MagicMock()
1156 expected_traceback = "python traceback"
1157 expected_msg = (
1158 "Exception caught during space check; please check space "
1159 "by hand. {}".format(expected_traceback)
1160 )
1161 mocker.patch.object(linter, "_handle_space_mismatch", side_effect=RuntimeError)
1162 mocker.patch.object(
1163 lint.traceback, "format_exc", return_value=expected_traceback
1164 )
1165 linter.logger = logger_mock
1166 linter.model.app_to_charm = self.check_spaces_example_app_charm_map
1167
1168 # Run the space check.
1169 # Based on the above bundle, we should have exactly one mismatch.
1170 linter.check_spaces(self.check_spaces_example_bundle)
1171
1172 logger_mock.warn.assert_called_once_with(expected_msg)
1173
1174 @pytest.mark.parametrize(
1175 "regex_error, check_value, actual_value",
1176 [
1177 (True, "same", "same"),
1178 (True, "same", "different"),
1179 (False, "same", "same"),
1180 (False, "same", "different"),
1181 ],
1182 )
1183 def test_helper_operator_check(
1184 self, regex_error, check_value, actual_value, mocker
1185 ):
1186 """Test comparing values using "helper_operator_check()" function."""
1187 if regex_error:
1188 mocker.patch.object(lint.re, "match", side_effect=lint.re.error(""))
1189
1190 expected_result = check_value == actual_value
1191
1192 result = lint.helper_operator_eq_check(check_value, actual_value)
1193
1194 assert bool(result) == expected_result
1195
1196 @pytest.mark.parametrize(
1197 "input_str, expected_int",
1198 [
1199 (1, 1), # return non-strings unchanged
1200 ("not_number_1", "not_number_1"), # return non-numbers unchanged
1201 ("not_number_g", "not_number_g"), # invalid value with valid suffix
1202 ("2f", "2f"), # unrecognized suffix returns value unchanged
1203 ("2k", 2000), # convert kilo suffix with quotient 1000
1204 ("2K", 2048), # convert Kilo suffix with quotient 1024
1205 ("2m", 2000000), # convert mega suffix with quotient 1000
1206 ("2M", 2097152), # convert Mega suffix with quotient 1024
1207 ("2g", 2000000000), # convert giga suffix with quotient 1000
1208 ("2G", 2147483648), # convert Giga suffix with quotient 1024
1209 ],
1210 )
1211 def test_linter_atoi(self, input_str, expected_int, linter):
1212 """Test conversion of string values (e.g. 2M (Megabytes)) to integers."""
1213 assert linter.atoi(input_str) == expected_int
diff --git a/tests/test_k8s.py b/tests/test_k8s.py
996new file mode 1006441214new file mode 100644
index 0000000..67835ad
--- /dev/null
+++ b/tests/test_k8s.py
@@ -0,0 +1,29 @@
1"""Tests for Kubernetes cloud module."""
2from unittest.mock import MagicMock
3
4from jujulint.k8s import Cloud, Kubernetes
5
6
7def test_init():
8 """Test Openstack cloud class initiation."""
9 cloud = Kubernetes(name="foo")
10 assert cloud.cloud_type == "kubernetes"
11
12
13def test_audit(mocker):
14 """Test openstack-specific steps of audit method.
15
16 Note: Currently this method does not do anything different than its
17 parent method.
18 """
19 audit_mock = mocker.patch.object(Cloud, "audit")
20 logger_mock = MagicMock()
21
22 name = "foo"
23 cloud = Kubernetes(name=name)
24 cloud.logger = logger_mock
25 expected_msg = "[{}] Running Kubernetes-specific audit steps.".format(name)
26 cloud.audit()
27
28 logger_mock.info.assert_called_once_with(expected_msg)
29 audit_mock.assert_called_once()
diff --git a/tests/test_logging.py b/tests/test_logging.py
0new file mode 10064430new file mode 100644
index 0000000..ad23ac7
--- /dev/null
+++ b/tests/test_logging.py
@@ -0,0 +1,216 @@
1"""Test for jujulint logging module."""
2import sys
3from unittest.mock import MagicMock, call
4
5import pytest
6
7from jujulint import logging
8
9
10def test_logger_init_with_handlers(mocker):
11 """Test initiation of a Logger instance with handlers already present."""
12 color_logger_mock = mocker.patch.object(logging, "colorlog")
13 color_logger_instance_mock = MagicMock()
14 color_logger_instance_mock.handlers = ["handler1", "handler2"]
15 color_logger_mock.getLogger.return_value = color_logger_instance_mock
16 set_level_mock = mocker.patch.object(logging.Logger, "set_level")
17
18 level = "Debug"
19 _ = logging.Logger(level=level)
20
21 color_logger_mock.getLogger.assert_called_once()
22 set_level_mock.assert_called_once_with(level)
23 # No new logger handlers were added since the logger instance already had some.
24 color_logger_instance_mock.addHandler.assert_not_called()
25
26
27@pytest.mark.parametrize("setup_file_logger", (False, True))
28def test_logger_init_without_handlers(setup_file_logger, mocker):
29 """Test initiation of a Logger instance that needs to create its own handlers.
30
31 This test has two variants, with and without setting up a file logger as well.
32 """
33 logfile = "/tmp/foo" if setup_file_logger else None
34 # Mock getLogger and resulting object
35 console_logger_mock = MagicMock()
36 file_logger_mock = MagicMock()
37 get_logger_mock = mocker.patch.object(
38 logging.colorlog,
39 "getLogger",
40 side_effect=[
41 console_logger_mock,
42 file_logger_mock,
43 ],
44 )
45 console_logger_mock.handlers = []
46
47 # Mock FileHandler and FileFormatter
48 filehandler_mock = MagicMock()
49 mocker.patch.object(logging.logging, "FileHandler", return_value=filehandler_mock)
50
51 file_formatter_mock = MagicMock()
52 mocker.patch.object(logging.logging, "Formatter", return_value=file_formatter_mock)
53
54 # Mock StreamHandler and resulting object
55 streamhandler_mock = MagicMock()
56 mocker.patch.object(
57 logging.colorlog, "StreamHandler", return_value=streamhandler_mock
58 )
59
60 set_level_mock = mocker.patch.object(logging.Logger, "set_level")
61 # Mock TTYColorFormatter
62 color_formatter_instance = MagicMock()
63 color_formatter_mock = mocker.patch.object(
64 logging.colorlog, "TTYColoredFormatter", return_value=color_formatter_instance
65 )
66 level = "Debug"
67 logformat_string = "%(log_color)s%(asctime)s [%(levelname)s] %(message)s"
68 date_format = "%Y-%m-%d %H:%M:%S"
69
70 _ = logging.Logger(level=level, logfile=logfile)
71
72 # Test creation of new log handler
73 set_level_mock.assert_called_once_with(level)
74 color_formatter_mock.assert_called_once_with(
75 logformat_string,
76 datefmt=date_format,
77 log_colors={
78 "DEBUG": "cyan",
79 "INFO": "green",
80 "WARNING": "yellow",
81 "ERROR": "red",
82 "CRITICAL": "red,bg_white",
83 },
84 stream=sys.stdout,
85 )
86 streamhandler_mock.setFormatter.assert_called_once_with(color_formatter_instance)
87
88 if not setup_file_logger:
89 # getLogger and addHandler are called only once if we are not setting up file
90 # logger
91 get_logger_mock.assert_called_once()
92 console_logger_mock.addHandler.assert_called_once_with(streamhandler_mock)
93 else:
94 # These steps need to be verified when __init__ sets up file logger as well
95 get_logger_mock.assert_has_calls([call(), call("file")])
96
97 logging.logging.Formatter.assert_called_once_with(
98 logformat_string, datefmt=date_format
99 )
100 assert not file_logger_mock.propagate
101 logging.logging.FileHandler.assert_called_once_with(logfile)
102 filehandler_mock.setFormatter.assert_called_once_with(file_formatter_mock)
103
104 console_logger_mock.addHandler.assert_has_calls(
105 [
106 call(streamhandler_mock),
107 call(filehandler_mock),
108 ]
109 )
110 file_logger_mock.addHandler.assert_called_once_with(filehandler_mock)
111
112
113@pytest.mark.parametrize("exit_code", [None, 0, 1, 2])
114def test_fubar(exit_code, mocker):
115 """Test method that prints to STDERR and ends program execution."""
116 err_msg = "foo bar"
117 expected_error = "E: {}\n".format(err_msg)
118
119 mocker.patch.object(logging.sys.stderr, "write")
120 mocker.patch.object(logging.sys, "exit")
121
122 if exit_code is None:
123 expected_exit_code = 1
124 logging.Logger.fubar(err_msg)
125 else:
126 expected_exit_code = exit_code
127 logging.Logger.fubar(err_msg, exit_code)
128
129 logging.sys.stderr.write.assert_called_once_with(expected_error)
130 logging.sys.exit.assert_called_once_with(expected_exit_code)
131
132
133@pytest.mark.parametrize(
134 "loglevel, expected_level",
135 [
136 ("DEBUG", logging.logging.DEBUG),
137 ("INFO", logging.logging.INFO),
138 ("WARN", logging.logging.WARN),
139 ("ERROR", logging.logging.ERROR),
140 ("Foo", logging.logging.INFO),
141 ],
142)
143def test_set_level(loglevel, expected_level, mocker):
144 """Test setting various log levels."""
145 bound_logger_mock = MagicMock()
146 mocker.patch.object(logging.colorlog, "getLogger", return_value=bound_logger_mock)
147 basic_config_mock = mocker.patch.object(logging.logging, "basicConfig")
148
149 logger = logging.Logger()
150 logger.set_level(loglevel)
151
152 if loglevel.lower() == "debug":
153 basic_config_mock.assert_called_once_with(level=expected_level)
154 else:
155 bound_logger_mock.setLevel.assert_called_once_with(expected_level)
156
157
158def test_debug_method(mocker):
159 """Test behavior of Logger.debug() method."""
160 message = "Log message"
161 bound_logger_mock = MagicMock()
162 mocker.patch.object(logging.colorlog, "getLogger", return_value=bound_logger_mock)
163
164 logger = logging.Logger()
165 logger.debug(message)
166
167 bound_logger_mock.debug.assert_called_once_with(message)
168
169
170def test_warn_method(mocker):
171 """Test behavior of Logger.warn() method."""
172 message = "Log message"
173 bound_logger_mock = MagicMock()
174 mocker.patch.object(logging.colorlog, "getLogger", return_value=bound_logger_mock)
175
176 logger = logging.Logger()
177 logger.warn(message)
178
179 bound_logger_mock.warn.assert_called_once_with(message)
180
181
182def test_info_method(mocker):
183 """Test behavior of Logger.info() method."""
184 message = "Log message"
185 bound_logger_mock = MagicMock()
186 mocker.patch.object(logging.colorlog, "getLogger", return_value=bound_logger_mock)
187
188 logger = logging.Logger()
189 logger.info(message)
190
191 bound_logger_mock.info.assert_called_once_with(message)
192
193
194def test_error_method(mocker):
195 """Test behavior of Logger.error() method."""
196 message = "Log message"
197 bound_logger_mock = MagicMock()
198 mocker.patch.object(logging.colorlog, "getLogger", return_value=bound_logger_mock)
199
200 logger = logging.Logger()
201 logger.error(message)
202
203 bound_logger_mock.error.assert_called_once_with(message)
204
205
206def test_log_method(mocker):
207 """Test behavior of Logger.log() method."""
208 message = "Log message"
209 level = logging.logging.INFO
210 bound_logger_mock = MagicMock()
211 mocker.patch.object(logging.colorlog, "getLogger", return_value=bound_logger_mock)
212
213 logger = logging.Logger()
214 logger.log(message, level)
215
216 bound_logger_mock.log.assert_called_once_with(level, message)
diff --git a/tests/test_openstack.py b/tests/test_openstack.py
0new file mode 100644217new file mode 100644
index 0000000..cd52b56
--- /dev/null
+++ b/tests/test_openstack.py
@@ -0,0 +1,29 @@
1"""Tests for OpenStack cloud module."""
2from unittest.mock import MagicMock
3
4from jujulint.openstack import Cloud, OpenStack
5
6
7def test_init():
8 """Test Openstack cloud class initiation."""
9 cloud = OpenStack(name="foo")
10 assert cloud.cloud_type == "openstack"
11
12
13def test_audit(mocker):
14 """Test openstack-specific steps of audit method.
15
16 Note: Currently this method does not do anything different than its
17 parent method.
18 """
19 audit_mock = mocker.patch.object(Cloud, "audit")
20 logger_mock = MagicMock()
21
22 name = "foo"
23 cloud = OpenStack(name=name)
24 cloud.logger = logger_mock
25 expected_msg = "[{}] Running OpenStack-specific audit steps.".format(name)
26 cloud.audit()
27
28 logger_mock.info.assert_called_once_with(expected_msg)
29 audit_mock.assert_called_once()
diff --git a/tox.ini b/tox.ini
index 40349c8..e37275c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -28,6 +28,7 @@ commands =
28 --new-first \28 --new-first \
29 --last-failed \29 --last-failed \
30 --last-failed-no-failures all \30 --last-failed-no-failures all \
31 --cov-fail-under=100 \
31 --cov-report=term-missing \32 --cov-report=term-missing \
32 --cov-report=annotate:tests/report/coverage-annotated \33 --cov-report=annotate:tests/report/coverage-annotated \
33 --cov-report=html:tests/report/coverage-html \34 --cov-report=html:tests/report/coverage-html \

Subscribers

People subscribed via source and target branches