Merge ~jfguedez/juju-lint:feature/add-json into juju-lint:master

Proposed by Jose Guedez
Status: Merged
Approved by: James Troup
Approved revision: 5074c659f8750b9811a6b1c2881c0efc8129ec33
Merged at revision: 76ca869ab423f56240cb3c24f885a8c2eb153bcb
Proposed branch: ~jfguedez/juju-lint:feature/add-json
Merge into: juju-lint:master
Prerequisite: ~jfguedez/juju-lint:fix-unit-tests
Diff against target: 967 lines (+595/-142)
5 files modified
jujulint/cli.py (+12/-1)
jujulint/config.py (+7/-0)
jujulint/lint.py (+238/-133)
tests/conftest.py (+63/-4)
tests/test_jujulint.py (+275/-4)
Reviewer Review Type Date Requested Status
Linda Guo (community) Approve
Juju Lint maintainers Pending
Review via email: mp+403129@code.launchpad.net

Commit message

Adds unit tests for the linter, as well as json output for the linting errors.

Description of the change

Adds unit tests for the linter, as well as json output for the linting errors:

    {
      "description": "Checks for mandatory Ops subordinates",
      "id": "ops-subordinate-missing",
      "message": "Subordinate 'hw-health' is missing for application(s): 'ubuntu'",
      "principals": "ubuntu",
      "subordinate": "hw-health",
      "tags": [
        "missing",
        "ops",
        "charm",
        "mandatory",
        "subordinate"
      ]
    },

collected 20 items

tests/test_cli.py::test_pytest PASSED [ 5%]
tests/test_cli.py::test_cli_fixture PASSED [ 10%]
tests/test_jujulint.py::test_flatten_list PASSED [ 15%]
tests/test_jujulint.py::test_map_charms PASSED [ 20%]
tests/test_jujulint.py::TestLinter::test_minimal_rules PASSED [ 25%]
tests/test_jujulint.py::TestLinter::test_charm_identification PASSED [ 30%]
tests/test_jujulint.py::TestLinter::test_juju_status_unexpected PASSED [ 35%]
tests/test_jujulint.py::TestLinter::test_AZ_parsing PASSED [ 40%]
tests/test_jujulint.py::TestLinter::test_AZ_balancing PASSED [ 45%]
tests/test_jujulint.py::TestLinter::test_ops_charm_missing PASSED [ 50%]
tests/test_jujulint.py::TestLinter::test_unrecognised_charm PASSED [ 55%]
tests/test_jujulint.py::TestLinter::test_ops_subordinate_missing PASSED [ 60%]
tests/test_jujulint.py::TestLinter::test_subordinate_extraneous PASSED [ 65%]
tests/test_jujulint.py::TestLinter::test_subordinate_duplicates PASSED [ 70%]
tests/test_jujulint.py::TestLinter::test_openstack_ops_charm_missing PASSED [ 75%]
tests/test_jujulint.py::TestLinter::test_openstack_charm_missing PASSED [ 80%]
tests/test_jujulint.py::TestLinter::test_config_eq PASSED [ 85%]
tests/test_jujulint.py::TestLinter::test_config_gte PASSED [ 90%]
tests/test_jujulint.py::TestLinter::test_config_isset_false PASSED [ 95%]
tests/test_jujulint.py::TestLinter::test_config_isset_true PASSED [100%]

===================================================================== 20 passed in 0.20s =====================================================================

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
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Unable to determine commit message from repository - please click "Set commit message" and enter the commit message manually.

Revision history for this message
Linda Guo (lihuiguo) wrote :

more unit tests were added. LGTM

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

Change has no commit message, setting status to needs review.

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

Change successfully merged at revision 76ca869ab423f56240cb3c24f885a8c2eb153bcb

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 8892420..aafe9a5 100755
--- a/jujulint/cli.py
+++ b/jujulint/cli.py
@@ -21,6 +21,7 @@ from jujulint.config import Config
21from jujulint.lint import Linter21from jujulint.lint import Linter
22from jujulint.logging import Logger22from jujulint.logging import Logger
23from jujulint.openstack import OpenStack23from jujulint.openstack import OpenStack
24import logging
24import os.path25import os.path
25import pkg_resources26import pkg_resources
26import yaml27import yaml
@@ -36,6 +37,11 @@ class Cli:
36 """Create new CLI and configure runtime environment."""37 """Create new CLI and configure runtime environment."""
37 self.config = Config()38 self.config = Config()
38 self.logger = Logger(self.config["logging"]["loglevel"].get())39 self.logger = Logger(self.config["logging"]["loglevel"].get())
40 self.output_format = self.config["format"].get()
41
42 # disable logging for non-text output formats
43 if self.output_format != "text":
44 logging.disable(level=logging.CRITICAL)
3945
40 # get the version of the current package if available46 # get the version of the current package if available
41 # this will fail if not running from an installed package47 # this will fail if not running from an installed package
@@ -79,7 +85,12 @@ class Cli:
79 def audit_file(self, filename, cloud_type=None):85 def audit_file(self, filename, cloud_type=None):
80 """Directly audit a YAML file."""86 """Directly audit a YAML file."""
81 self.logger.debug("Starting audit of file {}".format(filename))87 self.logger.debug("Starting audit of file {}".format(filename))
82 linter = Linter(filename, self.lint_rules, cloud_type=cloud_type,)88 linter = Linter(
89 filename,
90 self.lint_rules,
91 cloud_type=cloud_type,
92 output_format=self.output_format,
93 )
83 linter.read_rules()94 linter.read_rules()
84 self.logger.info("[{}] Linting manual file...".format(filename))95 self.logger.info("[{}] Linting manual file...".format(filename))
85 linter.lint_yaml_file(filename)96 linter.lint_yaml_file(filename)
diff --git a/jujulint/config.py b/jujulint/config.py
index 05b7883..1173187 100644
--- a/jujulint/config.py
+++ b/jujulint/config.py
@@ -96,6 +96,13 @@ class Config(Configuration):
96 help="File to log to in addition to stdout",96 help="File to log to in addition to stdout",
97 dest="logging.file",97 dest="logging.file",
98 )98 )
99 self.parser.add_argument(
100 "--format",
101 "-F",
102 choices=["text", "json"],
103 default="text",
104 help="Format for output",
105 )
99106
100 args = self.parser.parse_args()107 args = self.parser.parse_args()
101 self.set_args(args, dots=True)108 self.set_args(args, dots=True)
diff --git a/jujulint/lint.py b/jujulint/lint.py
index ce49398..4e2f70a 100755
--- a/jujulint/lint.py
+++ b/jujulint/lint.py
@@ -19,6 +19,7 @@
19# along with this program. If not, see <http://www.gnu.org/licenses/>.19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20"""Lint operations and rule processing engine."""20"""Lint operations and rule processing engine."""
21import collections21import collections
22import json
22import os.path23import os.path
23import pprint24import pprint
24import re25import re
@@ -65,6 +66,7 @@ class Linter:
65 model_name="manual",66 model_name="manual",
66 overrides=None,67 overrides=None,
67 cloud_type=None,68 cloud_type=None,
69 output_format="text",
68 ):70 ):
69 """Instantiate linter."""71 """Instantiate linter."""
70 self.logger = Logger()72 self.logger = Logger()
@@ -77,6 +79,19 @@ class Linter:
77 self.controller_name = controller_name79 self.controller_name = controller_name
78 self.model_name = model_name80 self.model_name = model_name
7981
82 # output
83 self.output_format = output_format
84 self.output_collector = {
85 "name": name,
86 "controller": controller_name,
87 "model": model_name,
88 "rules": filename,
89 "errors": [],
90 }
91
92 # collect errors only for non-text output (e.g. json)
93 self.collect_errors = True if self.output_format != "text" else False
94
80 def read_rules(self):95 def read_rules(self):
81 """Read and parse rules from YAML, optionally processing provided overrides."""96 """Read and parse rules from YAML, optionally processing provided overrides."""
82 if os.path.isfile(self.filename):97 if os.path.isfile(self.filename):
@@ -138,6 +153,7 @@ class Linter:
138 if sub in self.model.subs_on_machines[machine]:153 if sub in self.model.subs_on_machines[machine]:
139 self.model.duelling_subs.setdefault(sub, set())154 self.model.duelling_subs.setdefault(sub, set())
140 self.model.duelling_subs[sub].add(machine)155 self.model.duelling_subs[sub].add(machine)
156 self.model.subs_on_machines[machine].add(sub)
141 self.model.subs_on_machines[machine] = (157 self.model.subs_on_machines[machine] = (
142 set(subordinates) | self.model.subs_on_machines[machine]158 set(subordinates) | self.model.subs_on_machines[machine]
143 )159 )
@@ -146,7 +162,7 @@ class Linter:
146162
147 return163 return
148164
149 def atoi(val):165 def atoi(self, val):
150 """Deal with complex number representations as strings, returning a number."""166 """Deal with complex number representations as strings, returning a number."""
151 if type(val) != str:167 if type(val) != str:
152 return val168 return val
@@ -185,16 +201,21 @@ class Linter:
185 )201 )
186 )202 )
187 return True203 return True
188 self.logger.error(204
189 "[{}] [{}/{}] (FAIL) Application {} has config for {} which is less than {}: {}.".format(205 actual_value = config[rule]
190 self.cloud_name,206 self.handle_error(
191 self.controller_name,207 {
192 self.model_name,208 "id": "config-gte-check",
193 name,209 "tags": ["config", "gte"],
194 rule,210 "description": "Checks for config condition 'gte'",
195 check_value,211 "application": name,
196 config[rule],212 "rule": rule,
197 )213 "expected_value": check_value,
214 "actual_value": actual_value,
215 "message": "Application {} has config for {} which is less than {}: {}.".format(
216 name, rule, check_value, actual_value
217 ),
218 }
198 )219 )
199 return False220 return False
200 self.logger.warn(221 self.logger.warn(
@@ -224,15 +245,19 @@ class Linter:
224 )245 )
225 )246 )
226 return True247 return True
227 self.logger.error(248 actual_value = config[rule]
228 "[{}] [{}/{}] (FAIL) Application {} has manual config for {}: {}.".format(249 self.handle_error(
229 self.cloud_name,250 {
230 self.controller_name,251 "id": "config-isset-check-false",
231 self.model_name,252 "tags": ["config", "isset"],
232 name,253 "description": "Checks for config condition 'isset'",
233 rule,254 "application": name,
234 config[rule],255 "rule": rule,
235 )256 "actual_value": actual_value,
257 "message": "Application {} has manual config for {}: {}.".format(
258 name, rule, actual_value
259 ),
260 }
236 )261 )
237 return False262 return False
238 if check_value is False:263 if check_value is False:
@@ -246,14 +271,17 @@ class Linter:
246 )271 )
247 )272 )
248 return True273 return True
249 self.logger.error(274 self.handle_error(
250 "[{}] [{}/{}] (FAIL) Application {} has no manual config for {}.".format(275 {
251 self.cloud_name,276 "id": "config-isset-check-true",
252 self.controller_name,277 "tags": ["config", "isset"],
253 self.model_name,278 "description": "Checks for config condition 'isset' true",
254 name,279 "application": name,
255 rule,280 "rule": rule,
256 )281 "message": "Application {} has no manual config for {}.".format(
282 name, rule
283 ),
284 }
257 )285 )
258 return False286 return False
259287
@@ -278,16 +306,20 @@ class Linter:
278 )306 )
279 )307 )
280 return True308 return True
281 self.logger.error(309 actual_value = config[rule]
282 "[{}] [{}/{}] Application {} has incorrect setting for {}: Expected {}, got {}.".format(310 self.handle_error(
283 self.cloud_name,311 {
284 self.controller_name,312 "id": "config-eq-check",
285 self.model_name,313 "tags": ["config", "eq"],
286 name,314 "description": "Checks for config condition 'eq'",
287 rule,315 "application": name,
288 check_value,316 "rule": rule,
289 config[rule],317 "expected_value": check_value,
290 )318 "actual_value": actual_value,
319 "message": "Application {} has incorrect setting for {}: Expected {}, got {}.".format(
320 name, rule, check_value, actual_value
321 ),
322 }
291 )323 )
292 return False324 return False
293 else:325 else:
@@ -317,7 +349,7 @@ class Linter:
317 elif check_op == "eq":349 elif check_op == "eq":
318 self.eq(name, check_value, rule, config)350 self.eq(name, check_value, rule, config)
319 elif check_op == "gte":351 elif check_op == "gte":
320 self.eq(name, check_value, rule, config)352 self.gte(name, check_value, rule, config)
321 else:353 else:
322 self.logger.warn(354 self.logger.warn(
323 "[{}] [{}/{}] Application {} has unknown check operation for {}: {}.".format(355 "[{}] [{}/{}] Application {} has unknown check operation for {}: {}.".format(
@@ -331,7 +363,7 @@ class Linter:
331 )363 )
332364
333 def check_configuration(self, applications):365 def check_configuration(self, applications):
334 """Check applicaton configs in the model."""366 """Check application configs in the model."""
335 for application in applications.keys():367 for application in applications.keys():
336 # look for config rules for this application368 # look for config rules for this application
337 lint_rules = []369 lint_rules = []
@@ -610,113 +642,154 @@ class Linter:
610 """Check we recognise the charms which are in the model."""642 """Check we recognise the charms which are in the model."""
611 for charm in self.model.charms:643 for charm in self.model.charms:
612 if charm not in self.lint_rules["known charms"]:644 if charm not in self.lint_rules["known charms"]:
613 self.logger.error(645 self.handle_error(
614 "[{}] Charm '{}' in model {} on controller {} not recognised".format(646 {
615 self.cloud_name, charm, self.model_name, self.controller_name647 "id": "unrecognised-charm",
616 )648 "tags": ["charm", "unrecognised"],
649 "description": "An unrecognised charm is present in the model",
650 "charm": charm,
651 "message": "Charm '{}' not recognised".format(charm),
652 }
617 )653 )
618 # Then look for charms we require654 # Then look for charms we require
619 for charm in self.lint_rules["operations mandatory"]:655 for charm in self.lint_rules["operations mandatory"]:
620 if charm not in self.model.charms:656 if charm not in self.model.charms:
621 self.logger.error(657 self.handle_error(
622 "[{}] Ops charm '{}' in model {} on controller {} not found".format(658 {
623 self.cloud_name, charm, self.model_name, self.controller_name659 "id": "ops-charm-missing",
624 )660 "tags": ["missing", "ops", "charm", "mandatory", "principal"],
661 "description": "An Ops charm is missing",
662 "charm": charm,
663 "message": "Ops charm '{}' is missing".format(charm),
664 }
625 )665 )
626 if self.cloud_type == "openstack":666 if self.cloud_type == "openstack":
627 for charm in self.lint_rules["openstack mandatory"]:667 for charm in self.lint_rules["openstack mandatory"]:
628 if charm not in self.model.charms:668 if charm not in self.model.charms:
629 self.logger.error(669 self.handle_error(
630 "[{}] OpenStack charm '{}' in model {} on controller {} not found".format(670 {
631 self.cloud_name,671 "id": "openstack-charm-missing",
632 charm,672 "tags": [
633 self.model_name,673 "missing",
634 self.controller_name,674 "openstack",
635 )675 "charm",
676 "mandatory",
677 "principal",
678 ],
679 "description": "An Openstack charm is missing",
680 "charm": charm,
681 "message": "Openstack charm '{}' is missing".format(charm),
682 }
636 )683 )
637 for charm in self.lint_rules["operations openstack mandatory"]:684 for charm in self.lint_rules["operations openstack mandatory"]:
638 if charm not in self.model.charms:685 if charm not in self.model.charms:
639 self.logger.error(686 self.handle_error(
640 "[{}] Ops charm '{}' in OpenStack model {} on controller {} not found".format(687 {
641 self.cloud_name,688 "id": "openstack-ops-charm-missing",
642 charm,689 "tags": [
643 self.model_name,690 "missing",
644 self.controller_name,691 "openstack",
645 )692 "ops",
693 "charm",
694 "mandatory",
695 "principal",
696 ],
697 "description": "An Openstack ops charm is missing",
698 "charm": charm,
699 "message": "Openstack ops charm '{}' is missing".format(
700 charm
701 ),
702 }
646 )703 )
647 elif self.cloud_type == "kubernetes":704 elif self.cloud_type == "kubernetes":
648 for charm in self.lint_rules["kubernetes mandatory"]:705 for charm in self.lint_rules["kubernetes mandatory"]:
649 if charm not in self.model.charms:706 if charm not in self.model.charms:
650 self.logger.error(707 self.handle_error(
651 "[{}] [{}/{}] Kubernetes charm '{}' not found".format(708 {
652 self.cloud_name,709 "id": "kubernetes-charm-missing",
653 self.controller_name,710 "tags": [
654 self.model_name,711 "missing",
655 charm,712 "kubernetes",
656 )713 "charm",
714 "mandatory",
715 "principal",
716 ],
717 "description": "An Kubernetes charm is missing",
718 "charm": charm,
719 "message": "Kubernetes charm '{}' is missing".format(charm),
720 }
657 )721 )
658722
659 def results(self):723 def results(self):
660 """Provide results of the linting process."""724 """Provide results of the linting process."""
661 if self.model.missing_subs:725 if self.model.missing_subs:
662 self.logger.error("The following subordinates couldn't be found:")
663 for sub in self.model.missing_subs:726 for sub in self.model.missing_subs:
664 self.logger.error(727 principals = ", ".join(sorted(self.model.missing_subs[sub]))
665 "[{}] [{}/{}] -> {} [{}]".format(728 self.handle_error(
666 self.cloud_name,729 {
667 self.controller_name,730 "id": "ops-subordinate-missing",
668 self.model_name,731 "tags": ["missing", "ops", "charm", "mandatory", "subordinate"],
669 sub,732 "description": "Checks for mandatory Ops subordinates",
670 ", ".join(sorted(self.model.missing_subs[sub])),733 "principals": principals,
671 )734 "subordinate": sub,
735 "message": "Subordinate '{}' is missing for application(s): '{}'".format(
736 sub, principals
737 ),
738 }
672 )739 )
740
673 if self.model.extraneous_subs:741 if self.model.extraneous_subs:
674 self.logger.error("following subordinates where found unexpectedly:")
675 for sub in self.model.extraneous_subs:742 for sub in self.model.extraneous_subs:
676 self.logger.error(743 principals = ", ".join(sorted(self.model.extraneous_subs[sub]))
677 "[{}] [{}/{}] -> {} [{}]".format(744 self.handle_error(
678 self.cloud_name,745 {
679 self.controller_name,746 "id": "subordinate-extraneous",
680 self.model_name,747 "tags": ["extraneous", "charm", "subordinate"],
681 sub,748 "description": "Checks for extraneous subordinates in containers",
682 ", ".join(sorted(self.model.extraneous_subs[sub])),749 "principals": principals,
683 )750 "subordinate": sub,
751 "message": "Application(s) '{}' has extraneous subordinate '{}'".format(
752 principals, sub
753 ),
754 }
684 )755 )
685 if self.model.duelling_subs:756 if self.model.duelling_subs:
686 self.logger.error(
687 "[{}] [{}/{}] following subordinates where found on machines more than once:".format(
688 self.cloud_name,
689 self.controller_name,
690 self.model_name,
691 )
692 )
693 for sub in self.model.duelling_subs:757 for sub in self.model.duelling_subs:
694 self.logger.error(758 machines = ", ".join(sorted(self.model.duelling_subs[sub]))
695 "[{}] [{}/{}] -> {} [{}]".format(759 self.handle_error(
696 self.cloud_name,760 {
697 self.controller_name,761 "id": "subordinate-duplicate",
698 self.model_name,762 "tags": ["duplicate", "charm", "subordinate"],
699 sub,763 "description": "Checks for duplicate subordinates in a machine",
700 ", ".join(sorted(self.model.duelling_subs[sub])),764 "machines": machines,
701 )765 "subordinate": sub,
766 "message": "Subordinate '{}' is duplicated on machines: '{}'".format(
767 sub,
768 machines,
769 ),
770 }
702 )771 )
703 if self.model.az_unbalanced_apps:772 if self.model.az_unbalanced_apps:
704 self.logger.error("The following apps are unbalanced across AZs: ")
705 for app in self.model.az_unbalanced_apps:773 for app in self.model.az_unbalanced_apps:
706 (num_units, az_counter) = self.model.az_unbalanced_apps[app]774 (num_units, az_counter) = self.model.az_unbalanced_apps[app]
707 az_map = ", ".join(775 az_map = ", ".join(
708 ["{}: {}".format(az, az_counter[az]) for az in az_counter]776 ["{}: {}".format(az, az_counter[az]) for az in sorted(az_counter)]
709 )777 )
710 self.logger.error(778 self.handle_error(
711 "[{}] [{}/{}] -> {}: {} units, deployed as: {}".format(779 {
712 self.cloud_name,780 "id": "AZ-unbalance",
713 self.controller_name,781 "tags": ["AZ"],
714 self.model_name,782 "description": "Checks for application balance across AZs",
715 app,783 "application": app,
716 num_units,784 "num_units": num_units,
717 az_map,785 "az_map": az_map,
718 )786 "message": "Application '{}' is unbalanced across AZs: {} units, deployed as: {}".format(
787 app, num_units, az_map
788 ),
789 }
719 )790 )
791 if self.output_format == "json":
792 print(json.dumps(self.output_collector, indent=2, sort_keys=True))
720793
721 def map_charms(self, applications):794 def map_charms(self, applications):
722 """Process applications in the model, validating and normalising the names."""795 """Process applications in the model, validating and normalising the names."""
@@ -726,10 +799,16 @@ class Linter:
726 self.model.charms.add(charm_name)799 self.model.charms.add(charm_name)
727 self.model.app_to_charm[app] = charm_name800 self.model.app_to_charm[app] = charm_name
728 else:801 else:
729 self.logger.error(802 self.handle_error(
730 "[{}] [{}/{}] Could not detect which charm is used for application {}".format(803 {
731 self.cloud_name, self.controller_name, self.model_name, app804 "id": "charm-not-mapped",
732 )805 "tags": ["charm", "mapped", "parsing"],
806 "description": "Detect the charm used by an application",
807 "application": app,
808 "message": "Could not detect which charm is used for application {}".format(
809 app
810 ),
811 }
733 )812 )
734813
735 def map_machines_to_az(self, machines):814 def map_machines_to_az(self, machines):
@@ -760,20 +839,25 @@ class Linter:
760839
761 def check_status(self, what, status, expected):840 def check_status(self, what, status, expected):
762 """Lint the status of a unit."""841 """Lint the status of a unit."""
842 current_status = status.get("current")
763 if isinstance(expected, str):843 if isinstance(expected, str):
764 expected = [expected]844 expected = [expected]
765 if status.get("current") not in expected:845 if current_status not in expected:
766 self.logger.error(846 status_since = status.get("since")
767 "[{}] [{}/{}] {} has status '{}' (since: {}, message: {}); (We expected: {})".format(847 status_msg = status.get("message")
768 self.cloud_name,848 self.handle_error(
769 self.controller_name,849 {
770 self.model_name,850 "id": "status-unexpected",
771 what,851 "tags": ["status"],
772 status.get("current"),852 "description": "Checks for unexpected status in juju and workload",
773 status.get("since"),853 "what": what,
774 status.get("message"),854 "status_current": current_status,
775 expected,855 "status_since": status_since,
776 )856 "status_msg": status_msg,
857 "message": "{} has status '{}' (since: {}, message: {}); (We expected: {})".format(
858 what, current_status, status_since, status_msg, expected
859 ),
860 }
777 )861 )
778862
779 def check_status_pair(self, name, status_type, data_d):863 def check_status_pair(self, name, status_type, data_d):
@@ -866,10 +950,16 @@ class Linter:
866 azs.add(self.model.machines_to_az[machine])950 azs.add(self.model.machines_to_az[machine])
867 num_azs = len(azs)951 num_azs = len(azs)
868 if num_azs != 3:952 if num_azs != 3:
869 self.logger.error(953 self.handle_error(
870 "[{}] [{}/{}] Found {} AZs (not 3); and I don't currently know how to lint that.".format(954 {
871 self.cloud_name, self.controller_name, self.model_name, num_azs955 "id": "AZ-invalid-number",
872 )956 "tags": ["AZ"],
957 "description": "Checks for a valid number or AZs (currently 3)",
958 "num_azs": num_azs,
959 "message": "Invalid number of AZs: '{}', expecting 3".format(
960 num_azs
961 ),
962 }
873 )963 )
874 return964 return
875965
@@ -962,3 +1052,18 @@ class Linter:
962 self.model_name,1052 self.model_name,
963 )1053 )
964 )1054 )
1055
1056 def collect(self, error):
1057 """Collect an error and add it to the collector."""
1058 self.output_collector["errors"].append(error)
1059
1060 def handle_error(self, error):
1061 """Collect an error and add it to the collector."""
1062
1063 self.logger.error(
1064 "[{}] [{}/{}] {}.".format(
1065 self.cloud_name, self.controller_name, self.model_name, error["message"]
1066 )
1067 )
1068 if self.collect_errors:
1069 self.collect(error)
diff --git a/tests/conftest.py b/tests/conftest.py
index ef72390..ba9ed77 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,9 +29,12 @@ def mocked_pkg_resources(monkeypatch):
29@pytest.fixture29@pytest.fixture
30def cli(monkeypatch):30def cli(monkeypatch):
31 """Provide a test instance of the CLI class."""31 """Provide a test instance of the CLI class."""
32 monkeypatch.setattr(sys, "argv", ["juju-lint", "-c", "contrib/canonical-rules.yaml"])32 monkeypatch.setattr(
33 sys, "argv", ["juju-lint", "-c", "contrib/canonical-rules.yaml"]
34 )
3335
34 from jujulint.cli import Cli36 from jujulint.cli import Cli
37
35 cli = Cli()38 cli = Cli()
3639
37 return cli40 return cli
@@ -48,14 +51,70 @@ def utils():
48@pytest.fixture51@pytest.fixture
49def parser(monkeypatch):52def parser(monkeypatch):
50 """Mock the configuration parser."""53 """Mock the configuration parser."""
51 monkeypatch.setattr('jujulint.config.ArgumentParser', mock.Mock())54 monkeypatch.setattr("jujulint.config.ArgumentParser", mock.Mock())
5255
5356
54@pytest.fixture57@pytest.fixture
55def lint(parser):58def linter(parser):
56 """Provide test fixture for the linter class."""59 """Provide test fixture for the linter class."""
57 from jujulint.lint import Linter60 from jujulint.lint import Linter
5861
59 linter = Linter('mockcloud', 'mockrules.yaml')62 rules = {
63 "known charms": ["ntp", "ubuntu"],
64 "operations mandatory": ["ubuntu"],
65 "subordinates": {
66 "ntp": {"where": "all"},
67 },
68 }
69
70 linter = Linter("mockcloud", "mockrules.yaml")
71 linter.lint_rules = rules
72 linter.collect_errors = True
6073
61 return linter74 return linter
75
76
77@pytest.fixture
78def juju_status():
79 """Provide a base juju status for testing."""
80 return {
81 "applications": {
82 "ubuntu": {
83 "application-status": {"current": "active"},
84 "charm": "cs:ubuntu-18",
85 "charm-name": "ubuntu",
86 "relations": {"juju-info": ["ntp"]},
87 "units": {
88 "ubuntu/0": {
89 "juju-status": {"current": "idle"},
90 "machine": "0",
91 "subordinates": {
92 "ntp/0": {
93 "juju-status": {"current": "idle"},
94 "workload-status": {"current": "active"},
95 }
96 },
97 "workload-status": {"current": "active"},
98 }
99 },
100 },
101 },
102 "machines": {
103 "0": {
104 "hardware": "availability-zone=rack-1",
105 "juju-status": {"current": "started"},
106 "machine-status": {"current": "running"},
107 "modification-status": {"current": "applied"},
108 },
109 "1": {
110 "hardware": "availability-zone=rack-2",
111 "juju-status": {"current": "started"},
112 "machine-status": {"current": "running"},
113 },
114 "2": {
115 "hardware": "availability-zone=rack-3",
116 "juju-status": {"current": "started"},
117 "machine-status": {"current": "running"},
118 },
119 },
120 }
diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
index 4c27cc1..7dacb7d 100644
--- a/tests/test_jujulint.py
+++ b/tests/test_jujulint.py
@@ -16,7 +16,7 @@ def test_flatten_list(utils):
16 assert flattened_list == utils.flatten_list(unflattened_list)16 assert flattened_list == utils.flatten_list(unflattened_list)
1717
1818
19def test_map_charms(lint, utils):19def test_map_charms(linter, utils):
20 """Test the charm name validation code."""20 """Test the charm name validation code."""
21 applications = {21 applications = {
22 "test-app-1": {"charm": "cs:~USER/SERIES/TEST-CHARM12-123"},22 "test-app-1": {"charm": "cs:~USER/SERIES/TEST-CHARM12-123"},
@@ -26,11 +26,282 @@ def test_map_charms(lint, utils):
26 "test-app-5": {"charm": "local:TEST-CHARM12"},26 "test-app-5": {"charm": "local:TEST-CHARM12"},
27 "test-app-6": {"charm": "cs:~TEST-CHARMERS/TEST-CHARM12-123"},27 "test-app-6": {"charm": "cs:~TEST-CHARMERS/TEST-CHARM12-123"},
28 }28 }
29 lint.map_charms(applications)29 linter.map_charms(applications)
30 for charm in lint.model.charms:30 for charm in linter.model.charms:
31 assert "TEST-CHARM12" == charm31 assert "TEST-CHARM12" == charm
32 applications = {32 applications = {
33 "test-app1": {"charm": "cs:invalid-charm$"},33 "test-app1": {"charm": "cs:invalid-charm$"},
34 }34 }
35 with pytest.raises(utils.InvalidCharmNameError):35 with pytest.raises(utils.InvalidCharmNameError):
36 lint.map_charms(applications)36 linter.map_charms(applications)
37
38
39class TestLinter:
40 def test_minimal_rules(self, linter, juju_status):
41 """Test that the base rules/status have no errors."""
42 linter.do_lint(juju_status)
43 assert len(linter.output_collector["errors"]) == 0
44
45 def test_charm_identification(self, linter, juju_status):
46 """Test that applications are mapped to charms."""
47 juju_status["applications"]["ubuntu2"] = {
48 "application-status": {"current": "active"},
49 "charm-name": "ubuntu",
50 "relations": {"juju-info": ["ntp"]},
51 "units": {
52 "ubuntu2/0": {
53 "juju-status": {"current": "idle"},
54 "machine": "1",
55 "subordinates": {
56 "ntp/1": {
57 "juju-status": {"current": "idle"},
58 "workload-status": {"current": "error"},
59 }
60 },
61 "workload-status": {"current": "active"},
62 }
63 },
64 }
65 linter.do_lint(juju_status)
66
67 errors = linter.output_collector["errors"]
68 assert len(errors) == 1
69 assert errors[0]["id"] == "charm-not-mapped"
70 assert errors[0]["application"] == "ubuntu2"
71
72 def test_juju_status_unexpected(self, linter, juju_status):
73 """Test that juju and workload status is expected."""
74 # inject invalid status to the application
75 juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"][
76 "workload-status"
77 ].update(
78 {
79 "current": "error",
80 "since": "01 Apr 2021 05:14:13Z",
81 "message": 'hook failed: "install"',
82 }
83 )
84 linter.do_lint(juju_status)
85
86 errors = linter.output_collector["errors"]
87 assert len(errors) == 1
88 assert errors[0]["id"] == "status-unexpected"
89 assert errors[0]["status_current"] == "error"
90 assert errors[0]["status_since"] == "01 Apr 2021 05:14:13Z"
91 assert errors[0]["status_msg"] == 'hook failed: "install"'
92
93 def test_AZ_parsing(self, linter, juju_status):
94 """Test that the AZ parsing is working as expected."""
95 # duplicate a AZ name so we have 2 AZs instead of the expected 3
96 juju_status["machines"]["2"]["hardware"] = "availability-zone=rack-1"
97 linter.do_lint(juju_status)
98
99 errors = linter.output_collector["errors"]
100 assert len(errors) == 1
101 assert errors[0]["id"] == "AZ-invalid-number"
102 assert errors[0]["num_azs"] == 2
103
104 def test_AZ_balancing(self, linter, juju_status):
105 """Test that applications are balanced across AZs."""
106 # add an extra machine in an existing AZ
107 juju_status["machines"].update(
108 {
109 "3": {
110 "hardware": "availability-zone=rack-3",
111 "juju-status": {"current": "started"},
112 "machine-status": {"current": "running"},
113 "modification-status": {"current": "applied"},
114 }
115 }
116 )
117 # add two more ubuntu units, but unbalanced (ubuntu/0 is in rack-1)
118 juju_status["applications"]["ubuntu"]["units"].update(
119 {
120 "ubuntu/1": {
121 "juju-status": {"current": "idle"},
122 "machine": "2",
123 "subordinates": {
124 "ntp/1": {
125 "juju-status": {"current": "idle"},
126 "workload-status": {"current": "error"},
127 }
128 },
129 "workload-status": {"current": "active"},
130 },
131 "ubuntu/2": {
132 "juju-status": {"current": "idle"},
133 "machine": "3",
134 "subordinates": {
135 "ntp/2": {
136 "juju-status": {"current": "idle"},
137 "workload-status": {"current": "error"},
138 }
139 },
140 "workload-status": {"current": "active"},
141 },
142 }
143 )
144 linter.do_lint(juju_status)
145
146 errors = linter.output_collector["errors"]
147 assert len(errors) == 1
148 assert errors[0]["id"] == "AZ-unbalance"
149 assert errors[0]["application"] == "ubuntu"
150 assert errors[0]["num_units"] == 3
151 assert errors[0]["az_map"] == "rack-1: 1, rack-2: 0, rack-3: 2"
152
153 def test_ops_charm_missing(self, linter, juju_status):
154 """Test that missing ops mandatory charms are detected."""
155 # add a new mandatory ops charm
156 linter.lint_rules["operations mandatory"].append("telegraf")
157 linter.do_lint(juju_status)
158
159 errors = linter.output_collector["errors"]
160 assert len(errors) == 1
161 assert errors[0]["id"] == "ops-charm-missing"
162 assert errors[0]["charm"] == "telegraf"
163
164 def test_unrecognised_charm(self, linter, juju_status):
165 """Test that unrecognised charms are detected."""
166 # drop 'ubuntu' from the known charms
167 linter.lint_rules["known charms"] = ["ntp"]
168 linter.do_lint(juju_status)
169
170 errors = linter.output_collector["errors"]
171 assert len(errors) == 1
172 assert errors[0]["id"] == "unrecognised-charm"
173 assert errors[0]["charm"] == "ubuntu"
174
175 def test_ops_subordinate_missing(self, linter, juju_status):
176 """Test that missing ops subordinate charms are detected."""
177 # drop the subordinate unit
178 juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"]["subordinates"] = {}
179 linter.do_lint(juju_status)
180
181 errors = linter.output_collector["errors"]
182 assert len(errors) == 1
183 assert errors[0]["id"] == "ops-subordinate-missing"
184 assert errors[0]["principals"] == "ubuntu"
185 assert errors[0]["subordinate"] == "ntp"
186
187 def test_subordinate_extraneous(self, linter, juju_status):
188 """Test that extraneous subordinate charms are detected."""
189 # this check triggers on subordinates on containers that should only
190 # be present in hosts
191 linter.lint_rules["subordinates"]["ntp"]["where"] = "host only"
192 juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"][
193 "machine"
194 ] = "0/lxd/0"
195 linter.do_lint(juju_status)
196
197 errors = linter.output_collector["errors"]
198 assert len(errors) == 1
199 assert errors[0]["id"] == "subordinate-extraneous"
200 assert errors[0]["principals"] == "ubuntu"
201 assert errors[0]["subordinate"] == "ntp"
202
203 def test_subordinate_duplicates(self, linter, juju_status):
204 """Test that subordinate charms are not duplicated."""
205 ntp0 = juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"][
206 "subordinates"
207 ]["ntp/0"]
208 juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"]["subordinates"][
209 "ntp/1"
210 ] = ntp0
211 linter.do_lint(juju_status)
212
213 errors = linter.output_collector["errors"]
214 assert len(errors) == 1
215 assert errors[0]["id"] == "subordinate-duplicate"
216 assert errors[0]["machines"] == "0"
217 assert errors[0]["subordinate"] == "ntp"
218
219 def test_openstack_charm_missing(self, linter, juju_status):
220 """Test that missing openstack mandatory charms are detected."""
221 linter.cloud_type = "openstack"
222 linter.lint_rules["openstack mandatory"] = ["keystone"]
223 linter.lint_rules["operations openstack mandatory"] = ["ubuntu"]
224 linter.do_lint(juju_status)
225
226 errors = linter.output_collector["errors"]
227 assert len(errors) == 1
228 assert errors[0]["id"] == "openstack-charm-missing"
229 assert errors[0]["charm"] == "keystone"
230
231 def test_openstack_ops_charm_missing(self, linter, juju_status):
232 """Test that missing openstack mandatory ops charms are detected."""
233 linter.cloud_type = "openstack"
234 linter.lint_rules["openstack mandatory"] = ["ubuntu"]
235 linter.lint_rules["operations openstack mandatory"] = [
236 "openstack-service-checks"
237 ]
238 linter.do_lint(juju_status)
239
240 errors = linter.output_collector["errors"]
241 assert len(errors) == 1
242 assert errors[0]["id"] == "openstack-ops-charm-missing"
243 assert errors[0]["charm"] == "openstack-service-checks"
244
245 def test_openstack_charm_missing(self, linter, juju_status):
246 """Test that missing kubernetes mandatory charms are detected."""
247 linter.cloud_type = "kubernetes"
248 linter.lint_rules["kubernetes mandatory"] = ["kubernetes-master"]
249 linter.do_lint(juju_status)
250
251 errors = linter.output_collector["errors"]
252 assert len(errors) == 1
253 assert errors[0]["id"] == "kubernetes-charm-missing"
254 assert errors[0]["charm"] == "kubernetes-master"
255
256 def test_config_eq(self, linter, juju_status):
257 """Test the config condition 'eq'."""
258 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"eq": False}}}
259 juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": True}
260 linter.do_lint(juju_status)
261
262 errors = linter.output_collector["errors"]
263 assert len(errors) == 1
264 assert errors[0]["id"] == "config-eq-check"
265 assert errors[0]["application"] == "ubuntu"
266 assert errors[0]["rule"] == "fake-opt"
267 assert errors[0]["expected_value"] == False
268 assert errors[0]["actual_value"] == True
269
270 def test_config_gte(self, linter, juju_status):
271 """Test the config condition 'gte'."""
272 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"gte": 3}}}
273 juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": 0}
274 linter.do_lint(juju_status)
275
276 errors = linter.output_collector["errors"]
277 assert len(errors) == 1
278 assert errors[0]["id"] == "config-gte-check"
279 assert errors[0]["application"] == "ubuntu"
280 assert errors[0]["rule"] == "fake-opt"
281 assert errors[0]["expected_value"] == 3
282 assert errors[0]["actual_value"] == 0
283
284 def test_config_isset_false(self, linter, juju_status):
285 """Test the config condition 'isset' false."""
286 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": False}}}
287 juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": 0}
288 linter.do_lint(juju_status)
289
290 errors = linter.output_collector["errors"]
291 assert len(errors) == 1
292 assert errors[0]["id"] == "config-isset-check-false"
293 assert errors[0]["application"] == "ubuntu"
294 assert errors[0]["rule"] == "fake-opt"
295 assert errors[0]["actual_value"] == 0
296
297 def test_config_isset_true(self, linter, juju_status):
298 """Test the config condition 'isset' true."""
299 linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": True}}}
300 juju_status["applications"]["ubuntu"]["options"] = {}
301 linter.do_lint(juju_status)
302
303 errors = linter.output_collector["errors"]
304 assert len(errors) == 1
305 assert errors[0]["id"] == "config-isset-check-true"
306 assert errors[0]["application"] == "ubuntu"
307 assert errors[0]["rule"] == "fake-opt"

Subscribers

People subscribed via source and target branches