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 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
1diff --git a/jujulint/cli.py b/jujulint/cli.py
2index 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()
115diff --git a/jujulint/config.py b/jujulint/config.py
116index 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(
133diff --git a/jujulint/lint.py b/jujulint/lint.py
134index 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:
263diff --git a/jujulint/util.py b/jujulint/util.py
264index 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:
307diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
308index 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(
352diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
353index 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
360diff --git a/tests/functional/test_jujulint.py b/tests/functional/test_jujulint.py
361index 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."""
422diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
423index 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:
607diff --git a/tests/unit/test_jujulint.py b/tests/unit/test_jujulint.py
608index 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"

Subscribers

People subscribed via source and target branches