Merge ~mertkirpici/juju-lint:lp/1987359 into juju-lint:master
- Git
- lp:~mertkirpici/juju-lint
- lp/1987359
- Merge into master
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) |
||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gabriel Cocenza | Approve | ||
🤖 prod-jenkaas-bootstack | 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-
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.
- juju-lint now accepts urls as rules file command line arguments. i.e.
$ juju-lint -c https:/
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.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:70782cd290f
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
Robert Gildein (rgildein) wrote : | # |
2 not-blocking comments and one small issue.
Gabriel Cocenza (gabrielcocenza) wrote : | # |
Thanks for implementing this feature.
I've left some comments and suggestions in the code
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.
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".
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:/
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.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:d7316ceeb18
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
Gabriel Cocenza (gabrielcocenza) wrote : | # |
Thanks for the update. I've left couple more points that I think we can improve.
Mert Kirpici (mertkirpici) wrote : | # |
Hi Gabriel, thanks for the review!
I tried to address/answer the issues you raised in the update.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:8e08adef109
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
Gabriel Cocenza (gabrielcocenza) wrote : | # |
Just one more little thing regarding the urlopen and we will be ready to merge.
Mert Kirpici (mertkirpici) wrote : | # |
Applied the update. We should be good to go after the last CI run
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:30a938c9789
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:e741ba4c728
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:4c85ec66e9f
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 07d72e116d6e70e
Preview Diff
1 | diff --git a/jujulint/cli.py b/jujulint/cli.py |
2 | index ac85b2b..0c3c711 100755 |
3 | --- a/jujulint/cli.py |
4 | +++ b/jujulint/cli.py |
5 | @@ -30,6 +30,7 @@ from jujulint.config import Config |
6 | from jujulint.lint import Linter |
7 | from jujulint.logging import Logger |
8 | from jujulint.openstack import OpenStack |
9 | +from jujulint.util import is_url |
10 | |
11 | |
12 | class Cli: |
13 | @@ -55,16 +56,7 @@ class Cli: |
14 | except pkg_resources.DistributionNotFound: |
15 | self.version = "unknown" |
16 | |
17 | - rules_file = self.config["rules"]["file"].get() |
18 | - # handle absolute path provided |
19 | - if os.path.isfile(rules_file): |
20 | - self.lint_rules = rules_file |
21 | - elif os.path.isfile("{}/{}".format(self.config.config_dir(), rules_file)): |
22 | - # default to relative path |
23 | - self.lint_rules = "{}/{}".format(self.config.config_dir(), rules_file) |
24 | - else: |
25 | - self.logger.error("Cloud not locate rules file {}".format(rules_file)) |
26 | - sys.exit(1) |
27 | + self.rules_files = self.validate_rules_file_args() |
28 | |
29 | @property |
30 | def cloud_type(self): |
31 | @@ -90,6 +82,42 @@ class Cli: |
32 | manual_file = self.config["manual-file"].get() |
33 | return manual_file |
34 | |
35 | + def validate_rules_file_args(self): |
36 | + """Validate the given rules file arguments. |
37 | + |
38 | + :return: a list of validated and slightly adjusted |
39 | + paths/urls to the rules files. |
40 | + :rtype: list |
41 | + """ |
42 | + rules_file_args = [ |
43 | + rules_file.strip() |
44 | + for rules_file in self.config["rules"]["file"].get().split(",") |
45 | + if rules_file.strip() |
46 | + ] |
47 | + validated_rules_file_args = [] |
48 | + |
49 | + for arg in rules_file_args: |
50 | + # does not say anything about accessibility of the resource |
51 | + # pointed to by the url. we are just checking if the url |
52 | + # is well formed. |
53 | + if is_url(arg): |
54 | + validated_rules_file_args.append(arg) |
55 | + |
56 | + # absolute path provided |
57 | + elif os.path.isfile(arg): |
58 | + validated_rules_file_args.append(arg) |
59 | + |
60 | + # default to relative path |
61 | + elif os.path.isfile("{}/{}".format(self.config.config_dir(), arg)): |
62 | + validated_rules_file_args.append( |
63 | + "{}/{}".format(self.config.config_dir(), arg) |
64 | + ) |
65 | + else: |
66 | + self.logger.error("Cloud not locate rules file {}".format(arg)) |
67 | + sys.exit(1) |
68 | + |
69 | + return validated_rules_file_args |
70 | + |
71 | def startup_message(self): |
72 | """Print startup message to log.""" |
73 | self.logger.info( |
74 | @@ -98,14 +126,14 @@ class Cli: |
75 | "\t* Config directory: {}\n" |
76 | "\t* Cloud type: {}\n" |
77 | "\t* Manual file: {}\n" |
78 | - "\t* Rules file: {}\n" |
79 | + "\t* Rules files: {}\n" |
80 | "\t* Log level: {}\n" |
81 | ).format( |
82 | self.version, |
83 | self.config.config_dir(), |
84 | self.cloud_type or "Unknown", |
85 | self.manual_file or False, |
86 | - self.lint_rules, |
87 | + self.rules_files, |
88 | self.config["logging"]["loglevel"].get(), |
89 | ) |
90 | ) |
91 | @@ -119,11 +147,12 @@ class Cli: |
92 | self.logger.debug("Starting audit of file {}".format(filename)) |
93 | linter = Linter( |
94 | filename, |
95 | - self.lint_rules, |
96 | + self.rules_files, |
97 | cloud_type=cloud_type, |
98 | output_format=self.output_format, |
99 | ) |
100 | - linter.read_rules() |
101 | + if not linter.read_rules(): |
102 | + Logger.fubar("Error while reading the rules. Exiting...") |
103 | self.logger.info("[{}] Linting manual file...".format(filename)) |
104 | linter.lint_yaml_file(filename) |
105 | |
106 | @@ -160,7 +189,7 @@ class Cli: |
107 | access_method=access_method, |
108 | ssh_host=ssh_host, |
109 | sudo_user=sudo_user, |
110 | - lint_rules=self.lint_rules, |
111 | + lint_rules=self.rules_files, |
112 | ) |
113 | # refresh information |
114 | result = cloud_instance.refresh() |
115 | diff --git a/jujulint/config.py b/jujulint/config.py |
116 | index be2453e..16bb33d 100644 |
117 | --- a/jujulint/config.py |
118 | +++ b/jujulint/config.py |
119 | @@ -65,7 +65,12 @@ class Config(Configuration): |
120 | self.parser.add_argument( |
121 | "-c", |
122 | "--config", |
123 | - help="File to read lint rules from. Defaults to `lint-rules.yaml`", |
124 | + help=( |
125 | + "File to read lint rules from. Defaults to `lint-rules.yaml`. " |
126 | + "Also supports urls and comma separated multiple files. " |
127 | + "Note that if multiple files given, rules will be merged and existing " |
128 | + "rules will be overriden." |
129 | + ), |
130 | dest="rules.file", |
131 | ) |
132 | self.parser.add_argument( |
133 | diff --git a/jujulint/lint.py b/jujulint/lint.py |
134 | index 4219b2f..54267b2 100755 |
135 | --- a/jujulint/lint.py |
136 | +++ b/jujulint/lint.py |
137 | @@ -26,6 +26,8 @@ import pprint |
138 | import re |
139 | import traceback |
140 | from datetime import datetime, timezone |
141 | +from urllib.error import HTTPError, URLError |
142 | +from urllib.request import urlopen |
143 | |
144 | import attr |
145 | import dateutil.parser |
146 | @@ -106,7 +108,7 @@ class Linter: |
147 | def __init__( |
148 | self, |
149 | name, |
150 | - filename, |
151 | + rules_files, |
152 | controller_name="manual", |
153 | model_name="manual", |
154 | overrides=None, |
155 | @@ -117,7 +119,7 @@ class Linter: |
156 | self.logger = Logger() |
157 | self.lint_rules = {} |
158 | self.model = ModelInfo() |
159 | - self.filename = filename |
160 | + self.rules_files = rules_files |
161 | self.overrides = overrides |
162 | self.cloud_name = name |
163 | self.cloud_type = cloud_type |
164 | @@ -130,7 +132,7 @@ class Linter: |
165 | "name": name, |
166 | "controller": controller_name, |
167 | "model": model_name, |
168 | - "rules": filename, |
169 | + "rules": rules_files, |
170 | "errors": [], |
171 | } |
172 | |
173 | @@ -144,31 +146,50 @@ class Linter: |
174 | |
175 | def read_rules(self): |
176 | """Read and parse rules from YAML, optionally processing provided overrides.""" |
177 | - if os.path.isfile(self.filename): |
178 | - with open(self.filename, "r") as rules_file: |
179 | - raw_rules_txt = rules_file.read() |
180 | - |
181 | - self.lint_rules = self._process_includes_in_rules(raw_rules_txt) |
182 | - |
183 | - if self.overrides: |
184 | - for override in self.overrides.split("#"): |
185 | - (name, where) = override.split(":") |
186 | - self._log_with_header( |
187 | - "Overriding {} with {}".format(name, where), level=logging.INFO |
188 | + for rules_file in self.rules_files: |
189 | + if utils.is_url(rules_file): |
190 | + try: |
191 | + with urlopen(rules_file, timeout=10) as response: |
192 | + raw_rules_txt = response.read().decode() |
193 | + except (HTTPError, URLError) as error: |
194 | + self.logger.error( |
195 | + "Failed to fetch url: {} reason: {}".format( |
196 | + rules_file, error.reason |
197 | + ) |
198 | ) |
199 | - self.lint_rules["subordinates"][name] = dict(where=where) |
200 | + return False |
201 | + except TimeoutError: |
202 | + self.logger.error( |
203 | + "Failed to fetch url: {} reason: TimeoutError".format( |
204 | + rules_file |
205 | + ) |
206 | + ) |
207 | + return False |
208 | + elif os.path.isfile(rules_file): |
209 | + with open(rules_file, "r") as f: |
210 | + raw_rules_txt = f.read() |
211 | + else: |
212 | + self.logger.error("Rules file {} does not exist.".format(rules_file)) |
213 | + return False |
214 | + |
215 | + lint_rules = self._process_includes_in_rules(raw_rules_txt, rules_file) |
216 | |
217 | # Flatten all entries (to account for nesting due to YAML anchors (templating) |
218 | - self.lint_rules = { |
219 | - k: utils.flatten_list(v) for k, v in self.lint_rules.items() |
220 | - } |
221 | + lint_rules = {k: utils.flatten_list(v) for k, v in lint_rules.items()} |
222 | |
223 | - self._log_with_header( |
224 | - "Lint Rules: {}".format(pprint.pformat(self.lint_rules)) |
225 | - ) |
226 | - return True |
227 | - self.logger.error("Rules file {} does not exist.".format(self.filename)) |
228 | - return False |
229 | + # Update the current rules with the new ones |
230 | + self.lint_rules = utils.deep_update(self.lint_rules, lint_rules) |
231 | + |
232 | + if self.overrides: |
233 | + for override in self.overrides.split("#"): |
234 | + (name, where) = override.split(":") |
235 | + self._log_with_header( |
236 | + "Overriding {} with {}".format(name, where), level=logging.INFO |
237 | + ) |
238 | + self.lint_rules["subordinates"][name] = dict(where=where) |
239 | + |
240 | + self._log_with_header("Lint Rules: {}".format(pprint.pformat(self.lint_rules))) |
241 | + return True |
242 | |
243 | def process_subordinates(self, app_d, app_name): |
244 | """Iterate over subordinates and run subordinate checks.""" |
245 | @@ -1454,7 +1475,7 @@ class Linter: |
246 | if self.collect_errors and log_level == logging.ERROR: |
247 | self.collect(message) |
248 | |
249 | - def _process_includes_in_rules(self, yaml_txt): |
250 | + def _process_includes_in_rules(self, yaml_txt, filename): |
251 | """ |
252 | Process any includes in the rules file. |
253 | |
254 | @@ -1475,7 +1496,7 @@ class Linter: |
255 | ) |
256 | continue |
257 | |
258 | - include_path = os.path.join(os.path.dirname(self.filename), rel_path) |
259 | + include_path = os.path.join(os.path.dirname(filename), rel_path) |
260 | |
261 | if os.path.isfile(include_path): |
262 | with open(include_path, "r") as f: |
263 | diff --git a/jujulint/util.py b/jujulint/util.py |
264 | index 3b24419..0224fff 100644 |
265 | --- a/jujulint/util.py |
266 | +++ b/jujulint/util.py |
267 | @@ -19,7 +19,10 @@ |
268 | """Utility library for all helpful functions this project uses.""" |
269 | |
270 | import argparse |
271 | +import collections |
272 | import re |
273 | +from copy import deepcopy |
274 | +from urllib.parse import urlparse |
275 | |
276 | from jujulint.logging import Logger |
277 | |
278 | @@ -45,6 +48,28 @@ def flatten_list(lumpy_list): |
279 | return flat_list |
280 | |
281 | |
282 | +def deep_update(existing, new): |
283 | + """Deep update an existing dictionary with new dictionary.""" |
284 | + result = deepcopy(existing) |
285 | + |
286 | + def _deep_update_inplace(_existing, _new): |
287 | + """Perform an in-place recursive deep update of two dictionaries.""" |
288 | + for key, val in _new.items(): |
289 | + if isinstance(val, collections.abc.Mapping): |
290 | + _existing[key] = _deep_update_inplace(_existing.get(key, {}), val) |
291 | + else: |
292 | + _existing[key] = val |
293 | + return _existing |
294 | + |
295 | + return _deep_update_inplace(result, new) |
296 | + |
297 | + |
298 | +def is_url(string): |
299 | + """Determine if a string is a url.""" |
300 | + result = urlparse(string) |
301 | + return result.scheme and result.netloc |
302 | + |
303 | + |
304 | def is_container(machine): |
305 | """Check if a provided machine is a container.""" |
306 | if "lxd/" in machine: |
307 | diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py |
308 | index 6620b31..c1fae66 100644 |
309 | --- a/tests/functional/conftest.py |
310 | +++ b/tests/functional/conftest.py |
311 | @@ -82,6 +82,40 @@ def rules_file(basedir): |
312 | |
313 | |
314 | @pytest.fixture |
315 | +def rules_file_url(httpserver): |
316 | + """Return a http url for a rules file. |
317 | + |
318 | + This fixture will make use of the pytest-httpserver |
319 | + plugin and fire up a HTTP server locally for the juju-lint |
320 | + application to call urlopen() against. It also temporarily |
321 | + makes necessary changes to bypass the proxy if necessary. |
322 | + """ |
323 | + saved_no_proxy = os.environ.get("no_proxy", "") |
324 | + if "localhost" not in saved_no_proxy.split(","): |
325 | + os.environ["no_proxy"] = saved_no_proxy + ",localhost" |
326 | + |
327 | + endpoint = "/rules.yaml" |
328 | + rules_file_content = dedent( |
329 | + """ |
330 | + openstack config: |
331 | + mysql-innodb-cluster: |
332 | + max-connections: |
333 | + gte: 99999 |
334 | + """ |
335 | + ) |
336 | + httpserver.expect_request(endpoint).respond_with_data( |
337 | + response_data=rules_file_content |
338 | + ) |
339 | + |
340 | + yield httpserver.url_for(endpoint) |
341 | + |
342 | + if saved_no_proxy: |
343 | + os.environ["no_proxy"] = saved_no_proxy |
344 | + else: |
345 | + del os.environ["no_proxy"] |
346 | + |
347 | + |
348 | +@pytest.fixture |
349 | def manual_file(): |
350 | """Return the bundle file for testing.""" |
351 | return os.path.join( |
352 | diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt |
353 | index f7fed82..6a1cad4 100644 |
354 | --- a/tests/functional/requirements.txt |
355 | +++ b/tests/functional/requirements.txt |
356 | @@ -1,2 +1,3 @@ |
357 | pytest |
358 | pytest-operator |
359 | +pytest-httpserver |
360 | diff --git a/tests/functional/test_jujulint.py b/tests/functional/test_jujulint.py |
361 | index 9d908f1..24e382a 100644 |
362 | --- a/tests/functional/test_jujulint.py |
363 | +++ b/tests/functional/test_jujulint.py |
364 | @@ -1,7 +1,7 @@ |
365 | """Functional tests for juju-lint.""" |
366 | import json |
367 | import socket |
368 | -from subprocess import check_call, check_output |
369 | +from subprocess import PIPE, check_call, check_output, run |
370 | |
371 | import pytest |
372 | |
373 | @@ -34,6 +34,48 @@ def test_json_output(rules_file, manual_file): |
374 | ) |
375 | |
376 | |
377 | +@pytest.mark.smoke |
378 | +@pytest.mark.parametrize("arg", ["", "rules_file"]) |
379 | +def test_bad_url_among_the_rules_file_args_causes_crash(arg, manual_file, request): |
380 | + """Test that a bad url in the arguments causes a crash.""" |
381 | + bad_url = "foo://bad.url" |
382 | + cmdline_arg = bad_url |
383 | + if arg: |
384 | + rules_file = request.getfixturevalue(arg) |
385 | + cmdline_arg += f",{rules_file}" |
386 | + |
387 | + process = run( |
388 | + f"juju-lint -c {cmdline_arg} {manual_file}".split(), |
389 | + universal_newlines=True, |
390 | + stderr=PIPE, |
391 | + ) |
392 | + assert not process.returncode == 0 |
393 | + assert "Failed to fetch url" in process.stderr |
394 | + assert "Error while reading the rules. Exiting..." in process.stderr |
395 | + assert "Linting manual file" not in process.stderr |
396 | + |
397 | + |
398 | +@pytest.mark.smoke |
399 | +def test_multiple_rules_files_update_rules(rules_file, rules_file_url, manual_file): |
400 | + """Test that multiple rules files actually update the rules.""" |
401 | + error_string = "Application mysql-innodb-cluster has config for 'max-connections' which is less than" |
402 | + process = run( |
403 | + f"juju-lint -c {rules_file} {manual_file}".split(), |
404 | + universal_newlines=True, |
405 | + stderr=PIPE, |
406 | + ) |
407 | + assert process.returncode == 0 |
408 | + assert error_string not in process.stderr |
409 | + |
410 | + process = run( |
411 | + f"juju-lint -c {rules_file},{rules_file_url} {manual_file}".split(), |
412 | + universal_newlines=True, |
413 | + stderr=PIPE, |
414 | + ) |
415 | + assert process.returncode == 0 |
416 | + assert error_string in process.stderr |
417 | + |
418 | + |
419 | @pytest.mark.cloud |
420 | async def test_audit_local_cloud(ops_test, local_cloud, rules_file): |
421 | """Test running juju-lint against a live local cloud.""" |
422 | diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py |
423 | index 81d8934..3870185 100644 |
424 | --- a/tests/unit/test_cli.py |
425 | +++ b/tests/unit/test_cli.py |
426 | @@ -48,7 +48,7 @@ def test_cli_init(output_format_value, mocker): |
427 | |
428 | assert cli_instance.logger.logger.level == WARN |
429 | assert cli_instance.output_format == output_format_value |
430 | - assert cli_instance.lint_rules == rules_file_value |
431 | + assert cli_instance.rules_files == [rules_file_value] |
432 | |
433 | if output_format_value != "text": |
434 | logging_mock.disable.assert_called_once_with(level=logging_mock.CRITICAL) |
435 | @@ -80,7 +80,7 @@ def test_cli_ini_version(version, mocker): |
436 | assert cli_instance.version == expected_version |
437 | |
438 | |
439 | -@pytest.mark.parametrize("rules_path", ["absolute", "relative", None]) |
440 | +@pytest.mark.parametrize("rules_path", ["absolute", "relative", "url", None]) |
441 | def test_cli_init_rules_path(rules_path, mocker): |
442 | """Test different methods of loading rules file on Cli init. |
443 | |
444 | @@ -109,21 +109,52 @@ def test_cli_init_rules_path(rules_path, mocker): |
445 | mocker.patch.object(cli.os.path, "isfile", return_value=True) |
446 | elif rules_path == "relative": |
447 | mocker.patch.object(cli.os.path, "isfile", side_effect=[False, True]) |
448 | + elif rules_path == "url": |
449 | + mocker.patch.object(cli, "is_url", return_value=True) |
450 | else: |
451 | mocker.patch.object(cli.os.path, "isfile", return_value=False) |
452 | |
453 | cli_instance = cli.Cli() |
454 | |
455 | - if rules_path == "absolute": |
456 | - assert cli_instance.lint_rules == file_path |
457 | + if rules_path == "absolute" or rules_path == "url": |
458 | + assert cli_instance.rules_files == [file_path] |
459 | exit_mock.assert_not_called() |
460 | elif rules_path == "relative": |
461 | - assert cli_instance.lint_rules == "{}/{}".format(config_dir, file_path) |
462 | + assert cli_instance.rules_files == ["{}/{}".format(config_dir, file_path)] |
463 | exit_mock.assert_not_called() |
464 | else: |
465 | exit_mock.assert_called_once_with(1) |
466 | |
467 | |
468 | +@pytest.mark.parametrize( |
469 | + "rules_file_value", |
470 | + ( |
471 | + "/rule1.yaml, /rule2.yaml", |
472 | + ",,,/rule1.yaml,/rule2.yaml", |
473 | + "/rule1.yaml,/rule2.yaml,,,", |
474 | + " /rule1.yaml,, /rule2.yaml ", |
475 | + "/rule1.yaml,,,,,/rule2.yaml", |
476 | + ), |
477 | +) |
478 | +def test_cli_init_rules_file_comma_separated_values(rules_file_value, mocker): |
479 | + """Test that comma separated rules file values handle commas and spaces well.""" |
480 | + rules_file = MagicMock() |
481 | + rules_file.get.return_value = rules_file_value |
482 | + |
483 | + config = { |
484 | + "logging": {"loglevel": MagicMock()}, |
485 | + "format": MagicMock(), |
486 | + "rules": {"file": rules_file}, |
487 | + } |
488 | + |
489 | + mocker.patch.object(cli, "Config", return_value=config) |
490 | + mocker.patch.object(cli.os.path, "isfile", return_value=True) |
491 | + |
492 | + cli_instance = cli.Cli() |
493 | + |
494 | + assert cli_instance.rules_files == ["/rule1.yaml", "/rule2.yaml"] |
495 | + |
496 | + |
497 | @pytest.mark.parametrize("is_cloud_set", [True, False]) |
498 | def test_cli_cloud_type(cli_instance, is_cloud_set): |
499 | """Test cloud_type() property of Cli class.""" |
500 | @@ -167,7 +198,7 @@ def test_cli_startup_message(cli_instance, cloud_type_value, manual_file_value, |
501 | """Test output of a startup message.""" |
502 | version = "1.0" |
503 | config_dir = "/tmp/" |
504 | - lint_rules = "some rules" |
505 | + rules_files = ["rules.yaml"] |
506 | |
507 | log_level_value = "debug" |
508 | log_level = MagicMock() |
509 | @@ -192,20 +223,20 @@ def test_cli_startup_message(cli_instance, cloud_type_value, manual_file_value, |
510 | |
511 | expected_msg = ( |
512 | "juju-lint version {} starting...\n\t* Config directory: {}\n" |
513 | - "\t* Cloud type: {}\n\t* Manual file: {}\n\t* Rules file: {}\n" |
514 | + "\t* Cloud type: {}\n\t* Manual file: {}\n\t* Rules files: {}\n" |
515 | "\t* Log level: {}\n" |
516 | ).format( |
517 | version, |
518 | config_dir, |
519 | cloud_type_value or "Unknown", |
520 | manual_file_value or False, |
521 | - lint_rules, |
522 | + rules_files, |
523 | log_level_value, |
524 | ) |
525 | |
526 | cli_instance.version = version |
527 | cli_instance.config = config |
528 | - cli_instance.lint_rules = lint_rules |
529 | + cli_instance.rules_files = rules_files |
530 | log_mock = mocker.patch.object(cli_instance, "logger") |
531 | |
532 | assert cli_instance.cloud_type == cloud_type_value |
533 | @@ -227,13 +258,13 @@ def test_cli_usage(cli_instance): |
534 | def test_cli_audit_file(cli_instance, mocker): |
535 | """Test method audit_file() from Cli class.""" |
536 | filename = "/tmp/bundle.yaml" |
537 | - rules = "/tmp/rules.yaml" |
538 | + rules = ["/tmp/rules.yaml"] |
539 | cloud_type = "openstack" |
540 | output_format = "text" |
541 | linter_object = MagicMock() |
542 | |
543 | mock_linter = mocker.patch.object(cli, "Linter", return_value=linter_object) |
544 | - cli_instance.lint_rules = rules |
545 | + cli_instance.rules_files = rules |
546 | cli_instance.output_format = output_format |
547 | |
548 | cli_instance.audit_file(filename, cloud_type) |
549 | @@ -245,6 +276,30 @@ def test_cli_audit_file(cli_instance, mocker): |
550 | linter_object.lint_yaml_file.assert_called_once_with(filename) |
551 | |
552 | |
553 | +def test_cli_audit_file_exits_when_read_rule_fails(cli_instance, mocker): |
554 | + """Test method audit_file() from Cli class terminates when cannot read rules.""" |
555 | + filename = "/tmp/bundle.yaml" |
556 | + rules = ["/tmp/rules.yaml"] |
557 | + cloud_type = "openstack" |
558 | + output_format = "text" |
559 | + linter_object = MagicMock() |
560 | + linter_object.read_rules.return_value = False |
561 | + |
562 | + mock_linter = mocker.patch.object(cli, "Linter", return_value=linter_object) |
563 | + cli_instance.rules_files = rules |
564 | + cli_instance.output_format = output_format |
565 | + |
566 | + with pytest.raises(SystemExit) as mock_exception: |
567 | + cli_instance.audit_file(filename, cloud_type) |
568 | + |
569 | + assert mock_exception.value.code == 1 |
570 | + mock_linter.assert_called_once_with( |
571 | + filename, rules, cloud_type=cloud_type, output_format=output_format |
572 | + ) |
573 | + linter_object.read_rules.assert_called_once() |
574 | + linter_object.lint_yaml_file.assert_not_called() |
575 | + |
576 | + |
577 | def test_cli_audit_all(cli_instance, mocker): |
578 | """Test audit_all() method from Cli class.""" |
579 | audit_mock = mocker.patch.object(cli_instance, "audit") |
580 | @@ -276,7 +331,7 @@ def test_cli_audit_all(cli_instance, mocker): |
581 | def test_cli_audit(cli_instance, success, mocker): |
582 | """Test audit() method from Cli class.""" |
583 | cloud_name = "test cloud" |
584 | - lint_rules = "rules.yaml" |
585 | + rules_files = ["rules.yaml"] |
586 | cloud_data = { |
587 | "access": "ssh", |
588 | "sudo": "root", |
589 | @@ -303,7 +358,7 @@ def test_cli_audit(cli_instance, success, mocker): |
590 | cli_instance.logger = mock_logger |
591 | |
592 | cli_instance.config = config_data |
593 | - cli_instance.lint_rules = lint_rules |
594 | + cli_instance.rules_files = rules_files |
595 | |
596 | # assert cli_instance.config["clouds"] |
597 | cli_instance.audit(cloud_name=cloud_name) |
598 | @@ -313,7 +368,7 @@ def test_cli_audit(cli_instance, success, mocker): |
599 | access_method=cloud_data["access"], |
600 | ssh_host=cloud_data["host"], |
601 | sudo_user=cloud_data["sudo"], |
602 | - lint_rules=lint_rules, |
603 | + lint_rules=rules_files, |
604 | ) |
605 | |
606 | if success: |
607 | diff --git a/tests/unit/test_jujulint.py b/tests/unit/test_jujulint.py |
608 | index 8f548d6..8970d29 100644 |
609 | --- a/tests/unit/test_jujulint.py |
610 | +++ b/tests/unit/test_jujulint.py |
611 | @@ -2,6 +2,7 @@ |
612 | """Tests for jujulint.""" |
613 | import logging |
614 | from datetime import datetime, timezone |
615 | +from textwrap import dedent |
616 | from unittest import mock |
617 | |
618 | import pytest |
619 | @@ -1217,24 +1218,83 @@ applications: |
620 | rules_path = tmp_path / "rules.yaml" |
621 | rules_path.write_text('---\nkey:\n "value"') |
622 | |
623 | - linter.filename = str(rules_path) |
624 | + linter.lint_rules = {} |
625 | + linter.rules_files = [str(rules_path)] |
626 | result = linter.read_rules() |
627 | assert linter.lint_rules == {"key": "value"} |
628 | assert result |
629 | |
630 | + def test_read_rules_url(self, linter, mocker): |
631 | + """Test that rules YAML coming from urlopen works as expected.""" |
632 | + rules_content = b'---\nkey:\n "value"' |
633 | + mock_urlopen = mocker.patch.object(lint, "urlopen") |
634 | + mock_urlopen.return_value.__enter__.return_value.read.return_value = ( |
635 | + rules_content |
636 | + ) |
637 | + linter.lint_rules = {} |
638 | + linter.rules_files = ["https://rules.yaml"] |
639 | + result = linter.read_rules() |
640 | + assert linter.lint_rules == {"key": "value"} |
641 | + assert result |
642 | + |
643 | + def test_read_rules_multiple_files_update(self, tmp_path, linter, mocker): |
644 | + """Test if successive rules file contents update the repeated fields.""" |
645 | + rules_one_content = dedent( |
646 | + """ |
647 | + key_one: "foo" |
648 | + key_two: "bar" |
649 | + """ |
650 | + ) |
651 | + rules_two_content = dedent( |
652 | + """ |
653 | + key_one: "baz" |
654 | + """ |
655 | + ).encode() # urlopen will eventually yield a bytes object |
656 | + rules_one_path = tmp_path / "rules.yaml" |
657 | + rules_one_path.write_text(rules_one_content) |
658 | + mock_urlopen = mocker.patch.object(lint, "urlopen") |
659 | + mock_urlopen.return_value.__enter__.return_value.read.return_value = ( |
660 | + rules_two_content |
661 | + ) |
662 | + linter.lint_rules = {} |
663 | + linter.rules_files = [str(rules_one_path), "https://rules.yaml"] |
664 | + result = linter.read_rules() |
665 | + assert linter.lint_rules == {"key_one": "baz", "key_two": "bar"} |
666 | + assert result |
667 | + |
668 | + def test_read_rules_url_exception(self, linter, mocker): |
669 | + """Test read_rules() handles url exceptions.""" |
670 | + mock_urlopen = mocker.patch.object(lint, "urlopen") |
671 | + mock_urlopen.side_effect = lint.URLError("") |
672 | + linter.lint_rules = {} |
673 | + linter.rules_files = ["https://rules.yaml"] |
674 | + result = linter.read_rules() |
675 | + assert linter.lint_rules == {} |
676 | + assert result is False |
677 | + |
678 | + def test_read_rules_timeout_exception(self, linter, mocker): |
679 | + """Test read_rules() handles timeout exceptions.""" |
680 | + mock_urlopen = mocker.patch.object(lint, "urlopen") |
681 | + mock_urlopen.side_effect = TimeoutError("") |
682 | + linter.lint_rules = {} |
683 | + linter.rules_files = ["https://rules.yaml"] |
684 | + result = linter.read_rules() |
685 | + assert linter.lint_rules == {} |
686 | + assert result is False |
687 | + |
688 | def test_snap_rules_files(self, rules_files, linter): |
689 | """Ensure that all standard rules in the snap is loading correctly.""" |
690 | - for rule_file in rules_files: |
691 | + for rules_file in rules_files: |
692 | try: |
693 | - linter.filename = rule_file |
694 | + linter.rules_files = [rules_file] |
695 | linter.read_rules() |
696 | except yaml.YAMLError: |
697 | - pytest.fail(f"File: {rule_file} not loading") |
698 | + pytest.fail(f"File: {rules_file} not loading") |
699 | |
700 | def test_wrong_rule_file_raise_error(self, rules_files, linter, mocker): |
701 | """Test that bad formatted rules raise YAMLError.""" |
702 | rules_file = rules_files[0] |
703 | - linter.filename = rules_file |
704 | + linter.rules_files = [rules_file] |
705 | mocker.patch( |
706 | "jujulint.lint.Linter._process_includes_in_rules", |
707 | side_effect=yaml.YAMLError, |
708 | @@ -1250,7 +1310,8 @@ applications: |
709 | rules_path = tmp_path / "rules.yaml" |
710 | rules_path.write_text('---\n!include include.yaml\nkey:\n "value"') |
711 | |
712 | - linter.filename = str(rules_path) |
713 | + linter.lint_rules = {} |
714 | + linter.rules_files = [str(rules_path)] |
715 | result = linter.read_rules() |
716 | assert linter.lint_rules == {"key": "value", "key-inc": "value2"} |
717 | assert result |
718 | @@ -1262,7 +1323,8 @@ applications: |
719 | |
720 | linter.overrides = "override_1:value_1#override_2:value_2" |
721 | |
722 | - linter.filename = str(rules_path) |
723 | + linter.lint_rules = {} |
724 | + linter.rules_files = [str(rules_path)] |
725 | linter.read_rules() |
726 | assert linter.lint_rules == { |
727 | "key": "value", |
728 | @@ -1274,17 +1336,17 @@ applications: |
729 | |
730 | def test_read_rules_fail(self, linter, mocker): |
731 | """Test handling of a read_rules() failure.""" |
732 | - rule_file = "rules.yaml" |
733 | + rules_files = ["rules.yaml"] |
734 | mocker.patch.object(lint.os.path, "isfile", return_value=False) |
735 | logger_mock = mock.MagicMock() |
736 | linter.logger = logger_mock |
737 | - linter.filename = rule_file |
738 | + linter.rules_files = rules_files |
739 | |
740 | result = linter.read_rules() |
741 | |
742 | assert not result |
743 | logger_mock.error.assert_called_once_with( |
744 | - "Rules file {} does not exist.".format(rule_file) |
745 | + "Rules file {} does not exist.".format(rules_files[0]) |
746 | ) |
747 | |
748 | check_spaces_example_bundle = { |
749 | @@ -1706,7 +1768,8 @@ applications: |
750 | def test_path_mtu_for_ovs_ovn_rules(self, linter, rules_files): |
751 | """Test that ovs and ovn rules set right value of path-mtu.""" |
752 | for rule in [rules_file for rules_file in rules_files if "fcb" in rules_file]: |
753 | - linter.filename = rule |
754 | + linter.lint_rules = {} |
755 | + linter.rules_files = [rule] |
756 | linter.read_rules() |
757 | path_mtu = linter.lint_rules["openstack config"]["neutron-api"]["path-mtu"] |
758 | if "ovs" in rule: |
759 | @@ -1731,7 +1794,8 @@ applications: |
760 | ] |
761 | for rule in [rules_file for rules_file in rules_files if "fcb" in rules_file]: |
762 | for charm in charms: |
763 | - linter.filename = rule |
764 | + linter.lint_rules = {} |
765 | + linter.rules_files = [rule] |
766 | linter.read_rules() |
767 | worker_multiplier = linter.lint_rules["openstack config"][charm][ |
768 | "worker-multiplier" |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.