Merge ~mertkirpici/juju-lint:lp/1987359 into juju-lint:master

Proposed by Mert Kirpici
Status: Merged
Approved by: Erhan Sunar
Approved revision: 4c85ec66e9f542ce2c0492779043768fa27d9d80
Merged at revision: 07d72e116d6e70ea3a7d6d13c7d405fdf6db3b35
Proposed branch: ~mertkirpici/juju-lint:lp/1987359
Merge into: juju-lint:master
Prerequisite: ~mertkirpici/juju-lint:topic/coverage
Diff against target: 768 lines (+345/-69)
9 files modified
jujulint/cli.py (+44/-15)
jujulint/config.py (+6/-1)
jujulint/lint.py (+47/-26)
jujulint/util.py (+25/-0)
tests/functional/conftest.py (+34/-0)
tests/functional/requirements.txt (+1/-0)
tests/functional/test_jujulint.py (+43/-1)
tests/unit/test_cli.py (+69/-14)
tests/unit/test_jujulint.py (+76/-12)
Reviewer Review Type Date Requested Status
Gabriel Cocenza Approve
🤖 prod-jenkaas-bootstack (community) continuous-integration Approve
Erhan Sunar (community) Approve
BootStack Reviewers Pending
Review via email: mp+436319@code.launchpad.net

Commit message

Close LP #1987359

Description of the change

lint: add support for fetching rules over url

This patch extends the functionality of juju-lint in several ways:

- juju-lint now accepts a comma separated rules file list to be given through the command line and processes them in the order that they are given to update the rules. i.e.

    $ juju-lint -c ./rules-1.yaml,./rules-2.yaml ...

if the rules-2.yaml file overrides an option, in the resulting rules
dictionary, it will take precedence. Note that this is a deep update
operation, which means, it will follow through the nested dictionaries.
For implementation details, see jujulint.util.deep_update().

- juju-lint now accepts urls as rules file command line arguments. i.e.

    $ juju-lint -c https://git.launchpad.net/myrepo/plain/rules.yaml ..

the underlying implementation utilizes urllib which is part of the
python stdlib. Therefore what schemas it supports, we should be able to
support given that the resource is accessible to us.

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
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Robert Gildein (rgildein) wrote :

2 not-blocking comments and one small issue.

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

Thanks for implementing this feature.
I've left some comments and suggestions in the code

review: Needs Information
Revision history for this message
Andrea Ieri (aieri) wrote :

Please make sure this commit is merged to both master (principal implementation), and whichever branch MP#436414 gets merged to (subordinate implementation). Any future development should go to the subordinate implementation.

I'll also link this MP to the LP bug, the link is currently missing.

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

@aieri I think this change it's not suppose to affect MP#436414. This change it's on the snap package itself and not in the charm. However, the cham-juju-lint should be prepared to receive multiple rules file in the "lint-config".

Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Hi all,
I tried to address everything in the update(s), which I will push in a sec.

Also with Gabriel's suggestion to implement this feature as a comma separated list rather than specifying the "-c" command line option multiple times; i.e.

  $ juju-lint -c "file1.yaml,https://file2.yaml" ...

I think we can avoid code change in the charm-juju-lint completely. Just a description change in the config.yaml suggestion should be enough.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Erhan Sunar (esunar) wrote :

LGTM

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

Thanks for the update. I've left couple more points that I think we can improve.

review: Needs Information
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Hi Gabriel, thanks for the review!
I tried to address/answer the issues you raised in the update.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

Just one more little thing regarding the urlopen and we will be ready to merge.

Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Applied the update. We should be good to go after the last CI run

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

LGTM

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

Change successfully merged at revision 07d72e116d6e70ea3a7d6d13c7d405fdf6db3b35

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/jujulint/cli.py b/jujulint/cli.py
index ac85b2b..0c3c711 100755
--- a/jujulint/cli.py
+++ b/jujulint/cli.py
@@ -30,6 +30,7 @@ from jujulint.config import Config
30from jujulint.lint import Linter30from jujulint.lint import Linter
31from jujulint.logging import Logger31from jujulint.logging import Logger
32from jujulint.openstack import OpenStack32from jujulint.openstack import OpenStack
33from jujulint.util import is_url
3334
3435
35class Cli:36class Cli:
@@ -55,16 +56,7 @@ class Cli:
55 except pkg_resources.DistributionNotFound:56 except pkg_resources.DistributionNotFound:
56 self.version = "unknown"57 self.version = "unknown"
5758
58 rules_file = self.config["rules"]["file"].get()59 self.rules_files = self.validate_rules_file_args()
59 # handle absolute path provided
60 if os.path.isfile(rules_file):
61 self.lint_rules = rules_file
62 elif os.path.isfile("{}/{}".format(self.config.config_dir(), rules_file)):
63 # default to relative path
64 self.lint_rules = "{}/{}".format(self.config.config_dir(), rules_file)
65 else:
66 self.logger.error("Cloud not locate rules file {}".format(rules_file))
67 sys.exit(1)
6860
69 @property61 @property
70 def cloud_type(self):62 def cloud_type(self):
@@ -90,6 +82,42 @@ class Cli:
90 manual_file = self.config["manual-file"].get()82 manual_file = self.config["manual-file"].get()
91 return manual_file83 return manual_file
9284
85 def validate_rules_file_args(self):
86 """Validate the given rules file arguments.
87
88 :return: a list of validated and slightly adjusted
89 paths/urls to the rules files.
90 :rtype: list
91 """
92 rules_file_args = [
93 rules_file.strip()
94 for rules_file in self.config["rules"]["file"].get().split(",")
95 if rules_file.strip()
96 ]
97 validated_rules_file_args = []
98
99 for arg in rules_file_args:
100 # does not say anything about accessibility of the resource
101 # pointed to by the url. we are just checking if the url
102 # is well formed.
103 if is_url(arg):
104 validated_rules_file_args.append(arg)
105
106 # absolute path provided
107 elif os.path.isfile(arg):
108 validated_rules_file_args.append(arg)
109
110 # default to relative path
111 elif os.path.isfile("{}/{}".format(self.config.config_dir(), arg)):
112 validated_rules_file_args.append(
113 "{}/{}".format(self.config.config_dir(), arg)
114 )
115 else:
116 self.logger.error("Cloud not locate rules file {}".format(arg))
117 sys.exit(1)
118
119 return validated_rules_file_args
120
93 def startup_message(self):121 def startup_message(self):
94 """Print startup message to log."""122 """Print startup message to log."""
95 self.logger.info(123 self.logger.info(
@@ -98,14 +126,14 @@ class Cli:
98 "\t* Config directory: {}\n"126 "\t* Config directory: {}\n"
99 "\t* Cloud type: {}\n"127 "\t* Cloud type: {}\n"
100 "\t* Manual file: {}\n"128 "\t* Manual file: {}\n"
101 "\t* Rules file: {}\n"129 "\t* Rules files: {}\n"
102 "\t* Log level: {}\n"130 "\t* Log level: {}\n"
103 ).format(131 ).format(
104 self.version,132 self.version,
105 self.config.config_dir(),133 self.config.config_dir(),
106 self.cloud_type or "Unknown",134 self.cloud_type or "Unknown",
107 self.manual_file or False,135 self.manual_file or False,
108 self.lint_rules,136 self.rules_files,
109 self.config["logging"]["loglevel"].get(),137 self.config["logging"]["loglevel"].get(),
110 )138 )
111 )139 )
@@ -119,11 +147,12 @@ class Cli:
119 self.logger.debug("Starting audit of file {}".format(filename))147 self.logger.debug("Starting audit of file {}".format(filename))
120 linter = Linter(148 linter = Linter(
121 filename,149 filename,
122 self.lint_rules,150 self.rules_files,
123 cloud_type=cloud_type,151 cloud_type=cloud_type,
124 output_format=self.output_format,152 output_format=self.output_format,
125 )153 )
126 linter.read_rules()154 if not linter.read_rules():
155 Logger.fubar("Error while reading the rules. Exiting...")
127 self.logger.info("[{}] Linting manual file...".format(filename))156 self.logger.info("[{}] Linting manual file...".format(filename))
128 linter.lint_yaml_file(filename)157 linter.lint_yaml_file(filename)
129158
@@ -160,7 +189,7 @@ class Cli:
160 access_method=access_method,189 access_method=access_method,
161 ssh_host=ssh_host,190 ssh_host=ssh_host,
162 sudo_user=sudo_user,191 sudo_user=sudo_user,
163 lint_rules=self.lint_rules,192 lint_rules=self.rules_files,
164 )193 )
165 # refresh information194 # refresh information
166 result = cloud_instance.refresh()195 result = cloud_instance.refresh()
diff --git a/jujulint/config.py b/jujulint/config.py
index be2453e..16bb33d 100644
--- a/jujulint/config.py
+++ b/jujulint/config.py
@@ -65,7 +65,12 @@ class Config(Configuration):
65 self.parser.add_argument(65 self.parser.add_argument(
66 "-c",66 "-c",
67 "--config",67 "--config",
68 help="File to read lint rules from. Defaults to `lint-rules.yaml`",68 help=(
69 "File to read lint rules from. Defaults to `lint-rules.yaml`. "
70 "Also supports urls and comma separated multiple files. "
71 "Note that if multiple files given, rules will be merged and existing "
72 "rules will be overriden."
73 ),
69 dest="rules.file",74 dest="rules.file",
70 )75 )
71 self.parser.add_argument(76 self.parser.add_argument(
diff --git a/jujulint/lint.py b/jujulint/lint.py
index 4219b2f..54267b2 100755
--- a/jujulint/lint.py
+++ b/jujulint/lint.py
@@ -26,6 +26,8 @@ import pprint
26import re26import re
27import traceback27import traceback
28from datetime import datetime, timezone28from datetime import datetime, timezone
29from urllib.error import HTTPError, URLError
30from urllib.request import urlopen
2931
30import attr32import attr
31import dateutil.parser33import dateutil.parser
@@ -106,7 +108,7 @@ class Linter:
106 def __init__(108 def __init__(
107 self,109 self,
108 name,110 name,
109 filename,111 rules_files,
110 controller_name="manual",112 controller_name="manual",
111 model_name="manual",113 model_name="manual",
112 overrides=None,114 overrides=None,
@@ -117,7 +119,7 @@ class Linter:
117 self.logger = Logger()119 self.logger = Logger()
118 self.lint_rules = {}120 self.lint_rules = {}
119 self.model = ModelInfo()121 self.model = ModelInfo()
120 self.filename = filename122 self.rules_files = rules_files
121 self.overrides = overrides123 self.overrides = overrides
122 self.cloud_name = name124 self.cloud_name = name
123 self.cloud_type = cloud_type125 self.cloud_type = cloud_type
@@ -130,7 +132,7 @@ class Linter:
130 "name": name,132 "name": name,
131 "controller": controller_name,133 "controller": controller_name,
132 "model": model_name,134 "model": model_name,
133 "rules": filename,135 "rules": rules_files,
134 "errors": [],136 "errors": [],
135 }137 }
136138
@@ -144,31 +146,50 @@ class Linter:
144146
145 def read_rules(self):147 def read_rules(self):
146 """Read and parse rules from YAML, optionally processing provided overrides."""148 """Read and parse rules from YAML, optionally processing provided overrides."""
147 if os.path.isfile(self.filename):149 for rules_file in self.rules_files:
148 with open(self.filename, "r") as rules_file:150 if utils.is_url(rules_file):
149 raw_rules_txt = rules_file.read()151 try:
150152 with urlopen(rules_file, timeout=10) as response:
151 self.lint_rules = self._process_includes_in_rules(raw_rules_txt)153 raw_rules_txt = response.read().decode()
152154 except (HTTPError, URLError) as error:
153 if self.overrides:155 self.logger.error(
154 for override in self.overrides.split("#"):156 "Failed to fetch url: {} reason: {}".format(
155 (name, where) = override.split(":")157 rules_file, error.reason
156 self._log_with_header(158 )
157 "Overriding {} with {}".format(name, where), level=logging.INFO
158 )159 )
159 self.lint_rules["subordinates"][name] = dict(where=where)160 return False
161 except TimeoutError:
162 self.logger.error(
163 "Failed to fetch url: {} reason: TimeoutError".format(
164 rules_file
165 )
166 )
167 return False
168 elif os.path.isfile(rules_file):
169 with open(rules_file, "r") as f:
170 raw_rules_txt = f.read()
171 else:
172 self.logger.error("Rules file {} does not exist.".format(rules_file))
173 return False
174
175 lint_rules = self._process_includes_in_rules(raw_rules_txt, rules_file)
160176
161 # Flatten all entries (to account for nesting due to YAML anchors (templating)177 # Flatten all entries (to account for nesting due to YAML anchors (templating)
162 self.lint_rules = {178 lint_rules = {k: utils.flatten_list(v) for k, v in lint_rules.items()}
163 k: utils.flatten_list(v) for k, v in self.lint_rules.items()
164 }
165179
166 self._log_with_header(180 # Update the current rules with the new ones
167 "Lint Rules: {}".format(pprint.pformat(self.lint_rules))181 self.lint_rules = utils.deep_update(self.lint_rules, lint_rules)
168 )182
169 return True183 if self.overrides:
170 self.logger.error("Rules file {} does not exist.".format(self.filename))184 for override in self.overrides.split("#"):
171 return False185 (name, where) = override.split(":")
186 self._log_with_header(
187 "Overriding {} with {}".format(name, where), level=logging.INFO
188 )
189 self.lint_rules["subordinates"][name] = dict(where=where)
190
191 self._log_with_header("Lint Rules: {}".format(pprint.pformat(self.lint_rules)))
192 return True
172193
173 def process_subordinates(self, app_d, app_name):194 def process_subordinates(self, app_d, app_name):
174 """Iterate over subordinates and run subordinate checks."""195 """Iterate over subordinates and run subordinate checks."""
@@ -1454,7 +1475,7 @@ class Linter:
1454 if self.collect_errors and log_level == logging.ERROR:1475 if self.collect_errors and log_level == logging.ERROR:
1455 self.collect(message)1476 self.collect(message)
14561477
1457 def _process_includes_in_rules(self, yaml_txt):1478 def _process_includes_in_rules(self, yaml_txt, filename):
1458 """1479 """
1459 Process any includes in the rules file.1480 Process any includes in the rules file.
14601481
@@ -1475,7 +1496,7 @@ class Linter:
1475 )1496 )
1476 continue1497 continue
14771498
1478 include_path = os.path.join(os.path.dirname(self.filename), rel_path)1499 include_path = os.path.join(os.path.dirname(filename), rel_path)
14791500
1480 if os.path.isfile(include_path):1501 if os.path.isfile(include_path):
1481 with open(include_path, "r") as f:1502 with open(include_path, "r") as f:
diff --git a/jujulint/util.py b/jujulint/util.py
index 3b24419..0224fff 100644
--- a/jujulint/util.py
+++ b/jujulint/util.py
@@ -19,7 +19,10 @@
19"""Utility library for all helpful functions this project uses."""19"""Utility library for all helpful functions this project uses."""
2020
21import argparse21import argparse
22import collections
22import re23import re
24from copy import deepcopy
25from urllib.parse import urlparse
2326
24from jujulint.logging import Logger27from jujulint.logging import Logger
2528
@@ -45,6 +48,28 @@ def flatten_list(lumpy_list):
45 return flat_list48 return flat_list
4649
4750
51def deep_update(existing, new):
52 """Deep update an existing dictionary with new dictionary."""
53 result = deepcopy(existing)
54
55 def _deep_update_inplace(_existing, _new):
56 """Perform an in-place recursive deep update of two dictionaries."""
57 for key, val in _new.items():
58 if isinstance(val, collections.abc.Mapping):
59 _existing[key] = _deep_update_inplace(_existing.get(key, {}), val)
60 else:
61 _existing[key] = val
62 return _existing
63
64 return _deep_update_inplace(result, new)
65
66
67def is_url(string):
68 """Determine if a string is a url."""
69 result = urlparse(string)
70 return result.scheme and result.netloc
71
72
48def is_container(machine):73def is_container(machine):
49 """Check if a provided machine is a container."""74 """Check if a provided machine is a container."""
50 if "lxd/" in machine:75 if "lxd/" in machine:
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
index 6620b31..c1fae66 100644
--- a/tests/functional/conftest.py
+++ b/tests/functional/conftest.py
@@ -82,6 +82,40 @@ def rules_file(basedir):
8282
8383
84@pytest.fixture84@pytest.fixture
85def rules_file_url(httpserver):
86 """Return a http url for a rules file.
87
88 This fixture will make use of the pytest-httpserver
89 plugin and fire up a HTTP server locally for the juju-lint
90 application to call urlopen() against. It also temporarily
91 makes necessary changes to bypass the proxy if necessary.
92 """
93 saved_no_proxy = os.environ.get("no_proxy", "")
94 if "localhost" not in saved_no_proxy.split(","):
95 os.environ["no_proxy"] = saved_no_proxy + ",localhost"
96
97 endpoint = "/rules.yaml"
98 rules_file_content = dedent(
99 """
100 openstack config:
101 mysql-innodb-cluster:
102 max-connections:
103 gte: 99999
104 """
105 )
106 httpserver.expect_request(endpoint).respond_with_data(
107 response_data=rules_file_content
108 )
109
110 yield httpserver.url_for(endpoint)
111
112 if saved_no_proxy:
113 os.environ["no_proxy"] = saved_no_proxy
114 else:
115 del os.environ["no_proxy"]
116
117
118@pytest.fixture
85def manual_file():119def manual_file():
86 """Return the bundle file for testing."""120 """Return the bundle file for testing."""
87 return os.path.join(121 return os.path.join(
diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
index f7fed82..6a1cad4 100644
--- a/tests/functional/requirements.txt
+++ b/tests/functional/requirements.txt
@@ -1,2 +1,3 @@
1pytest1pytest
2pytest-operator2pytest-operator
3pytest-httpserver
diff --git a/tests/functional/test_jujulint.py b/tests/functional/test_jujulint.py
index 9d908f1..24e382a 100644
--- a/tests/functional/test_jujulint.py
+++ b/tests/functional/test_jujulint.py
@@ -1,7 +1,7 @@
1"""Functional tests for juju-lint."""1"""Functional tests for juju-lint."""
2import json2import json
3import socket3import socket
4from subprocess import check_call, check_output4from subprocess import PIPE, check_call, check_output, run
55
6import pytest6import pytest
77
@@ -34,6 +34,48 @@ def test_json_output(rules_file, manual_file):
34 )34 )
3535
3636
37@pytest.mark.smoke
38@pytest.mark.parametrize("arg", ["", "rules_file"])
39def test_bad_url_among_the_rules_file_args_causes_crash(arg, manual_file, request):
40 """Test that a bad url in the arguments causes a crash."""
41 bad_url = "foo://bad.url"
42 cmdline_arg = bad_url
43 if arg:
44 rules_file = request.getfixturevalue(arg)
45 cmdline_arg += f",{rules_file}"
46
47 process = run(
48 f"juju-lint -c {cmdline_arg} {manual_file}".split(),
49 universal_newlines=True,
50 stderr=PIPE,
51 )
52 assert not process.returncode == 0
53 assert "Failed to fetch url" in process.stderr
54 assert "Error while reading the rules. Exiting..." in process.stderr
55 assert "Linting manual file" not in process.stderr
56
57
58@pytest.mark.smoke
59def test_multiple_rules_files_update_rules(rules_file, rules_file_url, manual_file):
60 """Test that multiple rules files actually update the rules."""
61 error_string = "Application mysql-innodb-cluster has config for 'max-connections' which is less than"
62 process = run(
63 f"juju-lint -c {rules_file} {manual_file}".split(),
64 universal_newlines=True,
65 stderr=PIPE,
66 )
67 assert process.returncode == 0
68 assert error_string not in process.stderr
69
70 process = run(
71 f"juju-lint -c {rules_file},{rules_file_url} {manual_file}".split(),
72 universal_newlines=True,
73 stderr=PIPE,
74 )
75 assert process.returncode == 0
76 assert error_string in process.stderr
77
78
37@pytest.mark.cloud79@pytest.mark.cloud
38async def test_audit_local_cloud(ops_test, local_cloud, rules_file):80async def test_audit_local_cloud(ops_test, local_cloud, rules_file):
39 """Test running juju-lint against a live local cloud."""81 """Test running juju-lint against a live local cloud."""
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 81d8934..3870185 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -48,7 +48,7 @@ def test_cli_init(output_format_value, mocker):
4848
49 assert cli_instance.logger.logger.level == WARN49 assert cli_instance.logger.logger.level == WARN
50 assert cli_instance.output_format == output_format_value50 assert cli_instance.output_format == output_format_value
51 assert cli_instance.lint_rules == rules_file_value51 assert cli_instance.rules_files == [rules_file_value]
5252
53 if output_format_value != "text":53 if output_format_value != "text":
54 logging_mock.disable.assert_called_once_with(level=logging_mock.CRITICAL)54 logging_mock.disable.assert_called_once_with(level=logging_mock.CRITICAL)
@@ -80,7 +80,7 @@ def test_cli_ini_version(version, mocker):
80 assert cli_instance.version == expected_version80 assert cli_instance.version == expected_version
8181
8282
83@pytest.mark.parametrize("rules_path", ["absolute", "relative", None])83@pytest.mark.parametrize("rules_path", ["absolute", "relative", "url", None])
84def test_cli_init_rules_path(rules_path, mocker):84def test_cli_init_rules_path(rules_path, mocker):
85 """Test different methods of loading rules file on Cli init.85 """Test different methods of loading rules file on Cli init.
8686
@@ -109,21 +109,52 @@ def test_cli_init_rules_path(rules_path, mocker):
109 mocker.patch.object(cli.os.path, "isfile", return_value=True)109 mocker.patch.object(cli.os.path, "isfile", return_value=True)
110 elif rules_path == "relative":110 elif rules_path == "relative":
111 mocker.patch.object(cli.os.path, "isfile", side_effect=[False, True])111 mocker.patch.object(cli.os.path, "isfile", side_effect=[False, True])
112 elif rules_path == "url":
113 mocker.patch.object(cli, "is_url", return_value=True)
112 else:114 else:
113 mocker.patch.object(cli.os.path, "isfile", return_value=False)115 mocker.patch.object(cli.os.path, "isfile", return_value=False)
114116
115 cli_instance = cli.Cli()117 cli_instance = cli.Cli()
116118
117 if rules_path == "absolute":119 if rules_path == "absolute" or rules_path == "url":
118 assert cli_instance.lint_rules == file_path120 assert cli_instance.rules_files == [file_path]
119 exit_mock.assert_not_called()121 exit_mock.assert_not_called()
120 elif rules_path == "relative":122 elif rules_path == "relative":
121 assert cli_instance.lint_rules == "{}/{}".format(config_dir, file_path)123 assert cli_instance.rules_files == ["{}/{}".format(config_dir, file_path)]
122 exit_mock.assert_not_called()124 exit_mock.assert_not_called()
123 else:125 else:
124 exit_mock.assert_called_once_with(1)126 exit_mock.assert_called_once_with(1)
125127
126128
129@pytest.mark.parametrize(
130 "rules_file_value",
131 (
132 "/rule1.yaml, /rule2.yaml",
133 ",,,/rule1.yaml,/rule2.yaml",
134 "/rule1.yaml,/rule2.yaml,,,",
135 " /rule1.yaml,, /rule2.yaml ",
136 "/rule1.yaml,,,,,/rule2.yaml",
137 ),
138)
139def test_cli_init_rules_file_comma_separated_values(rules_file_value, mocker):
140 """Test that comma separated rules file values handle commas and spaces well."""
141 rules_file = MagicMock()
142 rules_file.get.return_value = rules_file_value
143
144 config = {
145 "logging": {"loglevel": MagicMock()},
146 "format": MagicMock(),
147 "rules": {"file": rules_file},
148 }
149
150 mocker.patch.object(cli, "Config", return_value=config)
151 mocker.patch.object(cli.os.path, "isfile", return_value=True)
152
153 cli_instance = cli.Cli()
154
155 assert cli_instance.rules_files == ["/rule1.yaml", "/rule2.yaml"]
156
157
127@pytest.mark.parametrize("is_cloud_set", [True, False])158@pytest.mark.parametrize("is_cloud_set", [True, False])
128def test_cli_cloud_type(cli_instance, is_cloud_set):159def test_cli_cloud_type(cli_instance, is_cloud_set):
129 """Test cloud_type() property of Cli class."""160 """Test cloud_type() property of Cli class."""
@@ -167,7 +198,7 @@ def test_cli_startup_message(cli_instance, cloud_type_value, manual_file_value,
167 """Test output of a startup message."""198 """Test output of a startup message."""
168 version = "1.0"199 version = "1.0"
169 config_dir = "/tmp/"200 config_dir = "/tmp/"
170 lint_rules = "some rules"201 rules_files = ["rules.yaml"]
171202
172 log_level_value = "debug"203 log_level_value = "debug"
173 log_level = MagicMock()204 log_level = MagicMock()
@@ -192,20 +223,20 @@ def test_cli_startup_message(cli_instance, cloud_type_value, manual_file_value,
192223
193 expected_msg = (224 expected_msg = (
194 "juju-lint version {} starting...\n\t* Config directory: {}\n"225 "juju-lint version {} starting...\n\t* Config directory: {}\n"
195 "\t* Cloud type: {}\n\t* Manual file: {}\n\t* Rules file: {}\n"226 "\t* Cloud type: {}\n\t* Manual file: {}\n\t* Rules files: {}\n"
196 "\t* Log level: {}\n"227 "\t* Log level: {}\n"
197 ).format(228 ).format(
198 version,229 version,
199 config_dir,230 config_dir,
200 cloud_type_value or "Unknown",231 cloud_type_value or "Unknown",
201 manual_file_value or False,232 manual_file_value or False,
202 lint_rules,233 rules_files,
203 log_level_value,234 log_level_value,
204 )235 )
205236
206 cli_instance.version = version237 cli_instance.version = version
207 cli_instance.config = config238 cli_instance.config = config
208 cli_instance.lint_rules = lint_rules239 cli_instance.rules_files = rules_files
209 log_mock = mocker.patch.object(cli_instance, "logger")240 log_mock = mocker.patch.object(cli_instance, "logger")
210241
211 assert cli_instance.cloud_type == cloud_type_value242 assert cli_instance.cloud_type == cloud_type_value
@@ -227,13 +258,13 @@ def test_cli_usage(cli_instance):
227def test_cli_audit_file(cli_instance, mocker):258def test_cli_audit_file(cli_instance, mocker):
228 """Test method audit_file() from Cli class."""259 """Test method audit_file() from Cli class."""
229 filename = "/tmp/bundle.yaml"260 filename = "/tmp/bundle.yaml"
230 rules = "/tmp/rules.yaml"261 rules = ["/tmp/rules.yaml"]
231 cloud_type = "openstack"262 cloud_type = "openstack"
232 output_format = "text"263 output_format = "text"
233 linter_object = MagicMock()264 linter_object = MagicMock()
234265
235 mock_linter = mocker.patch.object(cli, "Linter", return_value=linter_object)266 mock_linter = mocker.patch.object(cli, "Linter", return_value=linter_object)
236 cli_instance.lint_rules = rules267 cli_instance.rules_files = rules
237 cli_instance.output_format = output_format268 cli_instance.output_format = output_format
238269
239 cli_instance.audit_file(filename, cloud_type)270 cli_instance.audit_file(filename, cloud_type)
@@ -245,6 +276,30 @@ def test_cli_audit_file(cli_instance, mocker):
245 linter_object.lint_yaml_file.assert_called_once_with(filename)276 linter_object.lint_yaml_file.assert_called_once_with(filename)
246277
247278
279def test_cli_audit_file_exits_when_read_rule_fails(cli_instance, mocker):
280 """Test method audit_file() from Cli class terminates when cannot read rules."""
281 filename = "/tmp/bundle.yaml"
282 rules = ["/tmp/rules.yaml"]
283 cloud_type = "openstack"
284 output_format = "text"
285 linter_object = MagicMock()
286 linter_object.read_rules.return_value = False
287
288 mock_linter = mocker.patch.object(cli, "Linter", return_value=linter_object)
289 cli_instance.rules_files = rules
290 cli_instance.output_format = output_format
291
292 with pytest.raises(SystemExit) as mock_exception:
293 cli_instance.audit_file(filename, cloud_type)
294
295 assert mock_exception.value.code == 1
296 mock_linter.assert_called_once_with(
297 filename, rules, cloud_type=cloud_type, output_format=output_format
298 )
299 linter_object.read_rules.assert_called_once()
300 linter_object.lint_yaml_file.assert_not_called()
301
302
248def test_cli_audit_all(cli_instance, mocker):303def test_cli_audit_all(cli_instance, mocker):
249 """Test audit_all() method from Cli class."""304 """Test audit_all() method from Cli class."""
250 audit_mock = mocker.patch.object(cli_instance, "audit")305 audit_mock = mocker.patch.object(cli_instance, "audit")
@@ -276,7 +331,7 @@ def test_cli_audit_all(cli_instance, mocker):
276def test_cli_audit(cli_instance, success, mocker):331def test_cli_audit(cli_instance, success, mocker):
277 """Test audit() method from Cli class."""332 """Test audit() method from Cli class."""
278 cloud_name = "test cloud"333 cloud_name = "test cloud"
279 lint_rules = "rules.yaml"334 rules_files = ["rules.yaml"]
280 cloud_data = {335 cloud_data = {
281 "access": "ssh",336 "access": "ssh",
282 "sudo": "root",337 "sudo": "root",
@@ -303,7 +358,7 @@ def test_cli_audit(cli_instance, success, mocker):
303 cli_instance.logger = mock_logger358 cli_instance.logger = mock_logger
304359
305 cli_instance.config = config_data360 cli_instance.config = config_data
306 cli_instance.lint_rules = lint_rules361 cli_instance.rules_files = rules_files
307362
308 # assert cli_instance.config["clouds"]363 # assert cli_instance.config["clouds"]
309 cli_instance.audit(cloud_name=cloud_name)364 cli_instance.audit(cloud_name=cloud_name)
@@ -313,7 +368,7 @@ def test_cli_audit(cli_instance, success, mocker):
313 access_method=cloud_data["access"],368 access_method=cloud_data["access"],
314 ssh_host=cloud_data["host"],369 ssh_host=cloud_data["host"],
315 sudo_user=cloud_data["sudo"],370 sudo_user=cloud_data["sudo"],
316 lint_rules=lint_rules,371 lint_rules=rules_files,
317 )372 )
318373
319 if success:374 if success:
diff --git a/tests/unit/test_jujulint.py b/tests/unit/test_jujulint.py
index 8f548d6..8970d29 100644
--- a/tests/unit/test_jujulint.py
+++ b/tests/unit/test_jujulint.py
@@ -2,6 +2,7 @@
2"""Tests for jujulint."""2"""Tests for jujulint."""
3import logging3import logging
4from datetime import datetime, timezone4from datetime import datetime, timezone
5from textwrap import dedent
5from unittest import mock6from unittest import mock
67
7import pytest8import pytest
@@ -1217,24 +1218,83 @@ applications:
1217 rules_path = tmp_path / "rules.yaml"1218 rules_path = tmp_path / "rules.yaml"
1218 rules_path.write_text('---\nkey:\n "value"')1219 rules_path.write_text('---\nkey:\n "value"')
12191220
1220 linter.filename = str(rules_path)1221 linter.lint_rules = {}
1222 linter.rules_files = [str(rules_path)]
1221 result = linter.read_rules()1223 result = linter.read_rules()
1222 assert linter.lint_rules == {"key": "value"}1224 assert linter.lint_rules == {"key": "value"}
1223 assert result1225 assert result
12241226
1227 def test_read_rules_url(self, linter, mocker):
1228 """Test that rules YAML coming from urlopen works as expected."""
1229 rules_content = b'---\nkey:\n "value"'
1230 mock_urlopen = mocker.patch.object(lint, "urlopen")
1231 mock_urlopen.return_value.__enter__.return_value.read.return_value = (
1232 rules_content
1233 )
1234 linter.lint_rules = {}
1235 linter.rules_files = ["https://rules.yaml"]
1236 result = linter.read_rules()
1237 assert linter.lint_rules == {"key": "value"}
1238 assert result
1239
1240 def test_read_rules_multiple_files_update(self, tmp_path, linter, mocker):
1241 """Test if successive rules file contents update the repeated fields."""
1242 rules_one_content = dedent(
1243 """
1244 key_one: "foo"
1245 key_two: "bar"
1246 """
1247 )
1248 rules_two_content = dedent(
1249 """
1250 key_one: "baz"
1251 """
1252 ).encode() # urlopen will eventually yield a bytes object
1253 rules_one_path = tmp_path / "rules.yaml"
1254 rules_one_path.write_text(rules_one_content)
1255 mock_urlopen = mocker.patch.object(lint, "urlopen")
1256 mock_urlopen.return_value.__enter__.return_value.read.return_value = (
1257 rules_two_content
1258 )
1259 linter.lint_rules = {}
1260 linter.rules_files = [str(rules_one_path), "https://rules.yaml"]
1261 result = linter.read_rules()
1262 assert linter.lint_rules == {"key_one": "baz", "key_two": "bar"}
1263 assert result
1264
1265 def test_read_rules_url_exception(self, linter, mocker):
1266 """Test read_rules() handles url exceptions."""
1267 mock_urlopen = mocker.patch.object(lint, "urlopen")
1268 mock_urlopen.side_effect = lint.URLError("")
1269 linter.lint_rules = {}
1270 linter.rules_files = ["https://rules.yaml"]
1271 result = linter.read_rules()
1272 assert linter.lint_rules == {}
1273 assert result is False
1274
1275 def test_read_rules_timeout_exception(self, linter, mocker):
1276 """Test read_rules() handles timeout exceptions."""
1277 mock_urlopen = mocker.patch.object(lint, "urlopen")
1278 mock_urlopen.side_effect = TimeoutError("")
1279 linter.lint_rules = {}
1280 linter.rules_files = ["https://rules.yaml"]
1281 result = linter.read_rules()
1282 assert linter.lint_rules == {}
1283 assert result is False
1284
1225 def test_snap_rules_files(self, rules_files, linter):1285 def test_snap_rules_files(self, rules_files, linter):
1226 """Ensure that all standard rules in the snap is loading correctly."""1286 """Ensure that all standard rules in the snap is loading correctly."""
1227 for rule_file in rules_files:1287 for rules_file in rules_files:
1228 try:1288 try:
1229 linter.filename = rule_file1289 linter.rules_files = [rules_file]
1230 linter.read_rules()1290 linter.read_rules()
1231 except yaml.YAMLError:1291 except yaml.YAMLError:
1232 pytest.fail(f"File: {rule_file} not loading")1292 pytest.fail(f"File: {rules_file} not loading")
12331293
1234 def test_wrong_rule_file_raise_error(self, rules_files, linter, mocker):1294 def test_wrong_rule_file_raise_error(self, rules_files, linter, mocker):
1235 """Test that bad formatted rules raise YAMLError."""1295 """Test that bad formatted rules raise YAMLError."""
1236 rules_file = rules_files[0]1296 rules_file = rules_files[0]
1237 linter.filename = rules_file1297 linter.rules_files = [rules_file]
1238 mocker.patch(1298 mocker.patch(
1239 "jujulint.lint.Linter._process_includes_in_rules",1299 "jujulint.lint.Linter._process_includes_in_rules",
1240 side_effect=yaml.YAMLError,1300 side_effect=yaml.YAMLError,
@@ -1250,7 +1310,8 @@ applications:
1250 rules_path = tmp_path / "rules.yaml"1310 rules_path = tmp_path / "rules.yaml"
1251 rules_path.write_text('---\n!include include.yaml\nkey:\n "value"')1311 rules_path.write_text('---\n!include include.yaml\nkey:\n "value"')
12521312
1253 linter.filename = str(rules_path)1313 linter.lint_rules = {}
1314 linter.rules_files = [str(rules_path)]
1254 result = linter.read_rules()1315 result = linter.read_rules()
1255 assert linter.lint_rules == {"key": "value", "key-inc": "value2"}1316 assert linter.lint_rules == {"key": "value", "key-inc": "value2"}
1256 assert result1317 assert result
@@ -1262,7 +1323,8 @@ applications:
12621323
1263 linter.overrides = "override_1:value_1#override_2:value_2"1324 linter.overrides = "override_1:value_1#override_2:value_2"
12641325
1265 linter.filename = str(rules_path)1326 linter.lint_rules = {}
1327 linter.rules_files = [str(rules_path)]
1266 linter.read_rules()1328 linter.read_rules()
1267 assert linter.lint_rules == {1329 assert linter.lint_rules == {
1268 "key": "value",1330 "key": "value",
@@ -1274,17 +1336,17 @@ applications:
12741336
1275 def test_read_rules_fail(self, linter, mocker):1337 def test_read_rules_fail(self, linter, mocker):
1276 """Test handling of a read_rules() failure."""1338 """Test handling of a read_rules() failure."""
1277 rule_file = "rules.yaml"1339 rules_files = ["rules.yaml"]
1278 mocker.patch.object(lint.os.path, "isfile", return_value=False)1340 mocker.patch.object(lint.os.path, "isfile", return_value=False)
1279 logger_mock = mock.MagicMock()1341 logger_mock = mock.MagicMock()
1280 linter.logger = logger_mock1342 linter.logger = logger_mock
1281 linter.filename = rule_file1343 linter.rules_files = rules_files
12821344
1283 result = linter.read_rules()1345 result = linter.read_rules()
12841346
1285 assert not result1347 assert not result
1286 logger_mock.error.assert_called_once_with(1348 logger_mock.error.assert_called_once_with(
1287 "Rules file {} does not exist.".format(rule_file)1349 "Rules file {} does not exist.".format(rules_files[0])
1288 )1350 )
12891351
1290 check_spaces_example_bundle = {1352 check_spaces_example_bundle = {
@@ -1706,7 +1768,8 @@ applications:
1706 def test_path_mtu_for_ovs_ovn_rules(self, linter, rules_files):1768 def test_path_mtu_for_ovs_ovn_rules(self, linter, rules_files):
1707 """Test that ovs and ovn rules set right value of path-mtu."""1769 """Test that ovs and ovn rules set right value of path-mtu."""
1708 for rule in [rules_file for rules_file in rules_files if "fcb" in rules_file]:1770 for rule in [rules_file for rules_file in rules_files if "fcb" in rules_file]:
1709 linter.filename = rule1771 linter.lint_rules = {}
1772 linter.rules_files = [rule]
1710 linter.read_rules()1773 linter.read_rules()
1711 path_mtu = linter.lint_rules["openstack config"]["neutron-api"]["path-mtu"]1774 path_mtu = linter.lint_rules["openstack config"]["neutron-api"]["path-mtu"]
1712 if "ovs" in rule:1775 if "ovs" in rule:
@@ -1731,7 +1794,8 @@ applications:
1731 ]1794 ]
1732 for rule in [rules_file for rules_file in rules_files if "fcb" in rules_file]:1795 for rule in [rules_file for rules_file in rules_files if "fcb" in rules_file]:
1733 for charm in charms:1796 for charm in charms:
1734 linter.filename = rule1797 linter.lint_rules = {}
1798 linter.rules_files = [rule]
1735 linter.read_rules()1799 linter.read_rules()
1736 worker_multiplier = linter.lint_rules["openstack config"][charm][1800 worker_multiplier = linter.lint_rules["openstack config"][charm][
1737 "worker-multiplier"1801 "worker-multiplier"

Subscribers

People subscribed via source and target branches