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
1diff --git a/jujulint/cli.py b/jujulint/cli.py
2index 8892420..aafe9a5 100755
3--- a/jujulint/cli.py
4+++ b/jujulint/cli.py
5@@ -21,6 +21,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+import logging
10 import os.path
11 import pkg_resources
12 import yaml
13@@ -36,6 +37,11 @@ class Cli:
14 """Create new CLI and configure runtime environment."""
15 self.config = Config()
16 self.logger = Logger(self.config["logging"]["loglevel"].get())
17+ self.output_format = self.config["format"].get()
18+
19+ # disable logging for non-text output formats
20+ if self.output_format != "text":
21+ logging.disable(level=logging.CRITICAL)
22
23 # get the version of the current package if available
24 # this will fail if not running from an installed package
25@@ -79,7 +85,12 @@ class Cli:
26 def audit_file(self, filename, cloud_type=None):
27 """Directly audit a YAML file."""
28 self.logger.debug("Starting audit of file {}".format(filename))
29- linter = Linter(filename, self.lint_rules, cloud_type=cloud_type,)
30+ linter = Linter(
31+ filename,
32+ self.lint_rules,
33+ cloud_type=cloud_type,
34+ output_format=self.output_format,
35+ )
36 linter.read_rules()
37 self.logger.info("[{}] Linting manual file...".format(filename))
38 linter.lint_yaml_file(filename)
39diff --git a/jujulint/config.py b/jujulint/config.py
40index 05b7883..1173187 100644
41--- a/jujulint/config.py
42+++ b/jujulint/config.py
43@@ -96,6 +96,13 @@ class Config(Configuration):
44 help="File to log to in addition to stdout",
45 dest="logging.file",
46 )
47+ self.parser.add_argument(
48+ "--format",
49+ "-F",
50+ choices=["text", "json"],
51+ default="text",
52+ help="Format for output",
53+ )
54
55 args = self.parser.parse_args()
56 self.set_args(args, dots=True)
57diff --git a/jujulint/lint.py b/jujulint/lint.py
58index ce49398..4e2f70a 100755
59--- a/jujulint/lint.py
60+++ b/jujulint/lint.py
61@@ -19,6 +19,7 @@
62 # along with this program. If not, see <http://www.gnu.org/licenses/>.
63 """Lint operations and rule processing engine."""
64 import collections
65+import json
66 import os.path
67 import pprint
68 import re
69@@ -65,6 +66,7 @@ class Linter:
70 model_name="manual",
71 overrides=None,
72 cloud_type=None,
73+ output_format="text",
74 ):
75 """Instantiate linter."""
76 self.logger = Logger()
77@@ -77,6 +79,19 @@ class Linter:
78 self.controller_name = controller_name
79 self.model_name = model_name
80
81+ # output
82+ self.output_format = output_format
83+ self.output_collector = {
84+ "name": name,
85+ "controller": controller_name,
86+ "model": model_name,
87+ "rules": filename,
88+ "errors": [],
89+ }
90+
91+ # collect errors only for non-text output (e.g. json)
92+ self.collect_errors = True if self.output_format != "text" else False
93+
94 def read_rules(self):
95 """Read and parse rules from YAML, optionally processing provided overrides."""
96 if os.path.isfile(self.filename):
97@@ -138,6 +153,7 @@ class Linter:
98 if sub in self.model.subs_on_machines[machine]:
99 self.model.duelling_subs.setdefault(sub, set())
100 self.model.duelling_subs[sub].add(machine)
101+ self.model.subs_on_machines[machine].add(sub)
102 self.model.subs_on_machines[machine] = (
103 set(subordinates) | self.model.subs_on_machines[machine]
104 )
105@@ -146,7 +162,7 @@ class Linter:
106
107 return
108
109- def atoi(val):
110+ def atoi(self, val):
111 """Deal with complex number representations as strings, returning a number."""
112 if type(val) != str:
113 return val
114@@ -185,16 +201,21 @@ class Linter:
115 )
116 )
117 return True
118- self.logger.error(
119- "[{}] [{}/{}] (FAIL) Application {} has config for {} which is less than {}: {}.".format(
120- self.cloud_name,
121- self.controller_name,
122- self.model_name,
123- name,
124- rule,
125- check_value,
126- config[rule],
127- )
128+
129+ actual_value = config[rule]
130+ self.handle_error(
131+ {
132+ "id": "config-gte-check",
133+ "tags": ["config", "gte"],
134+ "description": "Checks for config condition 'gte'",
135+ "application": name,
136+ "rule": rule,
137+ "expected_value": check_value,
138+ "actual_value": actual_value,
139+ "message": "Application {} has config for {} which is less than {}: {}.".format(
140+ name, rule, check_value, actual_value
141+ ),
142+ }
143 )
144 return False
145 self.logger.warn(
146@@ -224,15 +245,19 @@ class Linter:
147 )
148 )
149 return True
150- self.logger.error(
151- "[{}] [{}/{}] (FAIL) Application {} has manual config for {}: {}.".format(
152- self.cloud_name,
153- self.controller_name,
154- self.model_name,
155- name,
156- rule,
157- config[rule],
158- )
159+ actual_value = config[rule]
160+ self.handle_error(
161+ {
162+ "id": "config-isset-check-false",
163+ "tags": ["config", "isset"],
164+ "description": "Checks for config condition 'isset'",
165+ "application": name,
166+ "rule": rule,
167+ "actual_value": actual_value,
168+ "message": "Application {} has manual config for {}: {}.".format(
169+ name, rule, actual_value
170+ ),
171+ }
172 )
173 return False
174 if check_value is False:
175@@ -246,14 +271,17 @@ class Linter:
176 )
177 )
178 return True
179- self.logger.error(
180- "[{}] [{}/{}] (FAIL) Application {} has no manual config for {}.".format(
181- self.cloud_name,
182- self.controller_name,
183- self.model_name,
184- name,
185- rule,
186- )
187+ self.handle_error(
188+ {
189+ "id": "config-isset-check-true",
190+ "tags": ["config", "isset"],
191+ "description": "Checks for config condition 'isset' true",
192+ "application": name,
193+ "rule": rule,
194+ "message": "Application {} has no manual config for {}.".format(
195+ name, rule
196+ ),
197+ }
198 )
199 return False
200
201@@ -278,16 +306,20 @@ class Linter:
202 )
203 )
204 return True
205- self.logger.error(
206- "[{}] [{}/{}] Application {} has incorrect setting for {}: Expected {}, got {}.".format(
207- self.cloud_name,
208- self.controller_name,
209- self.model_name,
210- name,
211- rule,
212- check_value,
213- config[rule],
214- )
215+ actual_value = config[rule]
216+ self.handle_error(
217+ {
218+ "id": "config-eq-check",
219+ "tags": ["config", "eq"],
220+ "description": "Checks for config condition 'eq'",
221+ "application": name,
222+ "rule": rule,
223+ "expected_value": check_value,
224+ "actual_value": actual_value,
225+ "message": "Application {} has incorrect setting for {}: Expected {}, got {}.".format(
226+ name, rule, check_value, actual_value
227+ ),
228+ }
229 )
230 return False
231 else:
232@@ -317,7 +349,7 @@ class Linter:
233 elif check_op == "eq":
234 self.eq(name, check_value, rule, config)
235 elif check_op == "gte":
236- self.eq(name, check_value, rule, config)
237+ self.gte(name, check_value, rule, config)
238 else:
239 self.logger.warn(
240 "[{}] [{}/{}] Application {} has unknown check operation for {}: {}.".format(
241@@ -331,7 +363,7 @@ class Linter:
242 )
243
244 def check_configuration(self, applications):
245- """Check applicaton configs in the model."""
246+ """Check application configs in the model."""
247 for application in applications.keys():
248 # look for config rules for this application
249 lint_rules = []
250@@ -610,113 +642,154 @@ class Linter:
251 """Check we recognise the charms which are in the model."""
252 for charm in self.model.charms:
253 if charm not in self.lint_rules["known charms"]:
254- self.logger.error(
255- "[{}] Charm '{}' in model {} on controller {} not recognised".format(
256- self.cloud_name, charm, self.model_name, self.controller_name
257- )
258+ self.handle_error(
259+ {
260+ "id": "unrecognised-charm",
261+ "tags": ["charm", "unrecognised"],
262+ "description": "An unrecognised charm is present in the model",
263+ "charm": charm,
264+ "message": "Charm '{}' not recognised".format(charm),
265+ }
266 )
267 # Then look for charms we require
268 for charm in self.lint_rules["operations mandatory"]:
269 if charm not in self.model.charms:
270- self.logger.error(
271- "[{}] Ops charm '{}' in model {} on controller {} not found".format(
272- self.cloud_name, charm, self.model_name, self.controller_name
273- )
274+ self.handle_error(
275+ {
276+ "id": "ops-charm-missing",
277+ "tags": ["missing", "ops", "charm", "mandatory", "principal"],
278+ "description": "An Ops charm is missing",
279+ "charm": charm,
280+ "message": "Ops charm '{}' is missing".format(charm),
281+ }
282 )
283 if self.cloud_type == "openstack":
284 for charm in self.lint_rules["openstack mandatory"]:
285 if charm not in self.model.charms:
286- self.logger.error(
287- "[{}] OpenStack charm '{}' in model {} on controller {} not found".format(
288- self.cloud_name,
289- charm,
290- self.model_name,
291- self.controller_name,
292- )
293+ self.handle_error(
294+ {
295+ "id": "openstack-charm-missing",
296+ "tags": [
297+ "missing",
298+ "openstack",
299+ "charm",
300+ "mandatory",
301+ "principal",
302+ ],
303+ "description": "An Openstack charm is missing",
304+ "charm": charm,
305+ "message": "Openstack charm '{}' is missing".format(charm),
306+ }
307 )
308 for charm in self.lint_rules["operations openstack mandatory"]:
309 if charm not in self.model.charms:
310- self.logger.error(
311- "[{}] Ops charm '{}' in OpenStack model {} on controller {} not found".format(
312- self.cloud_name,
313- charm,
314- self.model_name,
315- self.controller_name,
316- )
317+ self.handle_error(
318+ {
319+ "id": "openstack-ops-charm-missing",
320+ "tags": [
321+ "missing",
322+ "openstack",
323+ "ops",
324+ "charm",
325+ "mandatory",
326+ "principal",
327+ ],
328+ "description": "An Openstack ops charm is missing",
329+ "charm": charm,
330+ "message": "Openstack ops charm '{}' is missing".format(
331+ charm
332+ ),
333+ }
334 )
335 elif self.cloud_type == "kubernetes":
336 for charm in self.lint_rules["kubernetes mandatory"]:
337 if charm not in self.model.charms:
338- self.logger.error(
339- "[{}] [{}/{}] Kubernetes charm '{}' not found".format(
340- self.cloud_name,
341- self.controller_name,
342- self.model_name,
343- charm,
344- )
345+ self.handle_error(
346+ {
347+ "id": "kubernetes-charm-missing",
348+ "tags": [
349+ "missing",
350+ "kubernetes",
351+ "charm",
352+ "mandatory",
353+ "principal",
354+ ],
355+ "description": "An Kubernetes charm is missing",
356+ "charm": charm,
357+ "message": "Kubernetes charm '{}' is missing".format(charm),
358+ }
359 )
360
361 def results(self):
362 """Provide results of the linting process."""
363 if self.model.missing_subs:
364- self.logger.error("The following subordinates couldn't be found:")
365 for sub in self.model.missing_subs:
366- self.logger.error(
367- "[{}] [{}/{}] -> {} [{}]".format(
368- self.cloud_name,
369- self.controller_name,
370- self.model_name,
371- sub,
372- ", ".join(sorted(self.model.missing_subs[sub])),
373- )
374+ principals = ", ".join(sorted(self.model.missing_subs[sub]))
375+ self.handle_error(
376+ {
377+ "id": "ops-subordinate-missing",
378+ "tags": ["missing", "ops", "charm", "mandatory", "subordinate"],
379+ "description": "Checks for mandatory Ops subordinates",
380+ "principals": principals,
381+ "subordinate": sub,
382+ "message": "Subordinate '{}' is missing for application(s): '{}'".format(
383+ sub, principals
384+ ),
385+ }
386 )
387+
388 if self.model.extraneous_subs:
389- self.logger.error("following subordinates where found unexpectedly:")
390 for sub in self.model.extraneous_subs:
391- self.logger.error(
392- "[{}] [{}/{}] -> {} [{}]".format(
393- self.cloud_name,
394- self.controller_name,
395- self.model_name,
396- sub,
397- ", ".join(sorted(self.model.extraneous_subs[sub])),
398- )
399+ principals = ", ".join(sorted(self.model.extraneous_subs[sub]))
400+ self.handle_error(
401+ {
402+ "id": "subordinate-extraneous",
403+ "tags": ["extraneous", "charm", "subordinate"],
404+ "description": "Checks for extraneous subordinates in containers",
405+ "principals": principals,
406+ "subordinate": sub,
407+ "message": "Application(s) '{}' has extraneous subordinate '{}'".format(
408+ principals, sub
409+ ),
410+ }
411 )
412 if self.model.duelling_subs:
413- self.logger.error(
414- "[{}] [{}/{}] following subordinates where found on machines more than once:".format(
415- self.cloud_name,
416- self.controller_name,
417- self.model_name,
418- )
419- )
420 for sub in self.model.duelling_subs:
421- self.logger.error(
422- "[{}] [{}/{}] -> {} [{}]".format(
423- self.cloud_name,
424- self.controller_name,
425- self.model_name,
426- sub,
427- ", ".join(sorted(self.model.duelling_subs[sub])),
428- )
429+ machines = ", ".join(sorted(self.model.duelling_subs[sub]))
430+ self.handle_error(
431+ {
432+ "id": "subordinate-duplicate",
433+ "tags": ["duplicate", "charm", "subordinate"],
434+ "description": "Checks for duplicate subordinates in a machine",
435+ "machines": machines,
436+ "subordinate": sub,
437+ "message": "Subordinate '{}' is duplicated on machines: '{}'".format(
438+ sub,
439+ machines,
440+ ),
441+ }
442 )
443 if self.model.az_unbalanced_apps:
444- self.logger.error("The following apps are unbalanced across AZs: ")
445 for app in self.model.az_unbalanced_apps:
446 (num_units, az_counter) = self.model.az_unbalanced_apps[app]
447 az_map = ", ".join(
448- ["{}: {}".format(az, az_counter[az]) for az in az_counter]
449+ ["{}: {}".format(az, az_counter[az]) for az in sorted(az_counter)]
450 )
451- self.logger.error(
452- "[{}] [{}/{}] -> {}: {} units, deployed as: {}".format(
453- self.cloud_name,
454- self.controller_name,
455- self.model_name,
456- app,
457- num_units,
458- az_map,
459- )
460+ self.handle_error(
461+ {
462+ "id": "AZ-unbalance",
463+ "tags": ["AZ"],
464+ "description": "Checks for application balance across AZs",
465+ "application": app,
466+ "num_units": num_units,
467+ "az_map": az_map,
468+ "message": "Application '{}' is unbalanced across AZs: {} units, deployed as: {}".format(
469+ app, num_units, az_map
470+ ),
471+ }
472 )
473+ if self.output_format == "json":
474+ print(json.dumps(self.output_collector, indent=2, sort_keys=True))
475
476 def map_charms(self, applications):
477 """Process applications in the model, validating and normalising the names."""
478@@ -726,10 +799,16 @@ class Linter:
479 self.model.charms.add(charm_name)
480 self.model.app_to_charm[app] = charm_name
481 else:
482- self.logger.error(
483- "[{}] [{}/{}] Could not detect which charm is used for application {}".format(
484- self.cloud_name, self.controller_name, self.model_name, app
485- )
486+ self.handle_error(
487+ {
488+ "id": "charm-not-mapped",
489+ "tags": ["charm", "mapped", "parsing"],
490+ "description": "Detect the charm used by an application",
491+ "application": app,
492+ "message": "Could not detect which charm is used for application {}".format(
493+ app
494+ ),
495+ }
496 )
497
498 def map_machines_to_az(self, machines):
499@@ -760,20 +839,25 @@ class Linter:
500
501 def check_status(self, what, status, expected):
502 """Lint the status of a unit."""
503+ current_status = status.get("current")
504 if isinstance(expected, str):
505 expected = [expected]
506- if status.get("current") not in expected:
507- self.logger.error(
508- "[{}] [{}/{}] {} has status '{}' (since: {}, message: {}); (We expected: {})".format(
509- self.cloud_name,
510- self.controller_name,
511- self.model_name,
512- what,
513- status.get("current"),
514- status.get("since"),
515- status.get("message"),
516- expected,
517- )
518+ if current_status not in expected:
519+ status_since = status.get("since")
520+ status_msg = status.get("message")
521+ self.handle_error(
522+ {
523+ "id": "status-unexpected",
524+ "tags": ["status"],
525+ "description": "Checks for unexpected status in juju and workload",
526+ "what": what,
527+ "status_current": current_status,
528+ "status_since": status_since,
529+ "status_msg": status_msg,
530+ "message": "{} has status '{}' (since: {}, message: {}); (We expected: {})".format(
531+ what, current_status, status_since, status_msg, expected
532+ ),
533+ }
534 )
535
536 def check_status_pair(self, name, status_type, data_d):
537@@ -866,10 +950,16 @@ class Linter:
538 azs.add(self.model.machines_to_az[machine])
539 num_azs = len(azs)
540 if num_azs != 3:
541- self.logger.error(
542- "[{}] [{}/{}] Found {} AZs (not 3); and I don't currently know how to lint that.".format(
543- self.cloud_name, self.controller_name, self.model_name, num_azs
544- )
545+ self.handle_error(
546+ {
547+ "id": "AZ-invalid-number",
548+ "tags": ["AZ"],
549+ "description": "Checks for a valid number or AZs (currently 3)",
550+ "num_azs": num_azs,
551+ "message": "Invalid number of AZs: '{}', expecting 3".format(
552+ num_azs
553+ ),
554+ }
555 )
556 return
557
558@@ -962,3 +1052,18 @@ class Linter:
559 self.model_name,
560 )
561 )
562+
563+ def collect(self, error):
564+ """Collect an error and add it to the collector."""
565+ self.output_collector["errors"].append(error)
566+
567+ def handle_error(self, error):
568+ """Collect an error and add it to the collector."""
569+
570+ self.logger.error(
571+ "[{}] [{}/{}] {}.".format(
572+ self.cloud_name, self.controller_name, self.model_name, error["message"]
573+ )
574+ )
575+ if self.collect_errors:
576+ self.collect(error)
577diff --git a/tests/conftest.py b/tests/conftest.py
578index ef72390..ba9ed77 100644
579--- a/tests/conftest.py
580+++ b/tests/conftest.py
581@@ -29,9 +29,12 @@ def mocked_pkg_resources(monkeypatch):
582 @pytest.fixture
583 def cli(monkeypatch):
584 """Provide a test instance of the CLI class."""
585- monkeypatch.setattr(sys, "argv", ["juju-lint", "-c", "contrib/canonical-rules.yaml"])
586+ monkeypatch.setattr(
587+ sys, "argv", ["juju-lint", "-c", "contrib/canonical-rules.yaml"]
588+ )
589
590 from jujulint.cli import Cli
591+
592 cli = Cli()
593
594 return cli
595@@ -48,14 +51,70 @@ def utils():
596 @pytest.fixture
597 def parser(monkeypatch):
598 """Mock the configuration parser."""
599- monkeypatch.setattr('jujulint.config.ArgumentParser', mock.Mock())
600+ monkeypatch.setattr("jujulint.config.ArgumentParser", mock.Mock())
601
602
603 @pytest.fixture
604-def lint(parser):
605+def linter(parser):
606 """Provide test fixture for the linter class."""
607 from jujulint.lint import Linter
608
609- linter = Linter('mockcloud', 'mockrules.yaml')
610+ rules = {
611+ "known charms": ["ntp", "ubuntu"],
612+ "operations mandatory": ["ubuntu"],
613+ "subordinates": {
614+ "ntp": {"where": "all"},
615+ },
616+ }
617+
618+ linter = Linter("mockcloud", "mockrules.yaml")
619+ linter.lint_rules = rules
620+ linter.collect_errors = True
621
622 return linter
623+
624+
625+@pytest.fixture
626+def juju_status():
627+ """Provide a base juju status for testing."""
628+ return {
629+ "applications": {
630+ "ubuntu": {
631+ "application-status": {"current": "active"},
632+ "charm": "cs:ubuntu-18",
633+ "charm-name": "ubuntu",
634+ "relations": {"juju-info": ["ntp"]},
635+ "units": {
636+ "ubuntu/0": {
637+ "juju-status": {"current": "idle"},
638+ "machine": "0",
639+ "subordinates": {
640+ "ntp/0": {
641+ "juju-status": {"current": "idle"},
642+ "workload-status": {"current": "active"},
643+ }
644+ },
645+ "workload-status": {"current": "active"},
646+ }
647+ },
648+ },
649+ },
650+ "machines": {
651+ "0": {
652+ "hardware": "availability-zone=rack-1",
653+ "juju-status": {"current": "started"},
654+ "machine-status": {"current": "running"},
655+ "modification-status": {"current": "applied"},
656+ },
657+ "1": {
658+ "hardware": "availability-zone=rack-2",
659+ "juju-status": {"current": "started"},
660+ "machine-status": {"current": "running"},
661+ },
662+ "2": {
663+ "hardware": "availability-zone=rack-3",
664+ "juju-status": {"current": "started"},
665+ "machine-status": {"current": "running"},
666+ },
667+ },
668+ }
669diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
670index 4c27cc1..7dacb7d 100644
671--- a/tests/test_jujulint.py
672+++ b/tests/test_jujulint.py
673@@ -16,7 +16,7 @@ def test_flatten_list(utils):
674 assert flattened_list == utils.flatten_list(unflattened_list)
675
676
677-def test_map_charms(lint, utils):
678+def test_map_charms(linter, utils):
679 """Test the charm name validation code."""
680 applications = {
681 "test-app-1": {"charm": "cs:~USER/SERIES/TEST-CHARM12-123"},
682@@ -26,11 +26,282 @@ def test_map_charms(lint, utils):
683 "test-app-5": {"charm": "local:TEST-CHARM12"},
684 "test-app-6": {"charm": "cs:~TEST-CHARMERS/TEST-CHARM12-123"},
685 }
686- lint.map_charms(applications)
687- for charm in lint.model.charms:
688+ linter.map_charms(applications)
689+ for charm in linter.model.charms:
690 assert "TEST-CHARM12" == charm
691 applications = {
692 "test-app1": {"charm": "cs:invalid-charm$"},
693 }
694 with pytest.raises(utils.InvalidCharmNameError):
695- lint.map_charms(applications)
696+ linter.map_charms(applications)
697+
698+
699+class TestLinter:
700+ def test_minimal_rules(self, linter, juju_status):
701+ """Test that the base rules/status have no errors."""
702+ linter.do_lint(juju_status)
703+ assert len(linter.output_collector["errors"]) == 0
704+
705+ def test_charm_identification(self, linter, juju_status):
706+ """Test that applications are mapped to charms."""
707+ juju_status["applications"]["ubuntu2"] = {
708+ "application-status": {"current": "active"},
709+ "charm-name": "ubuntu",
710+ "relations": {"juju-info": ["ntp"]},
711+ "units": {
712+ "ubuntu2/0": {
713+ "juju-status": {"current": "idle"},
714+ "machine": "1",
715+ "subordinates": {
716+ "ntp/1": {
717+ "juju-status": {"current": "idle"},
718+ "workload-status": {"current": "error"},
719+ }
720+ },
721+ "workload-status": {"current": "active"},
722+ }
723+ },
724+ }
725+ linter.do_lint(juju_status)
726+
727+ errors = linter.output_collector["errors"]
728+ assert len(errors) == 1
729+ assert errors[0]["id"] == "charm-not-mapped"
730+ assert errors[0]["application"] == "ubuntu2"
731+
732+ def test_juju_status_unexpected(self, linter, juju_status):
733+ """Test that juju and workload status is expected."""
734+ # inject invalid status to the application
735+ juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"][
736+ "workload-status"
737+ ].update(
738+ {
739+ "current": "error",
740+ "since": "01 Apr 2021 05:14:13Z",
741+ "message": 'hook failed: "install"',
742+ }
743+ )
744+ linter.do_lint(juju_status)
745+
746+ errors = linter.output_collector["errors"]
747+ assert len(errors) == 1
748+ assert errors[0]["id"] == "status-unexpected"
749+ assert errors[0]["status_current"] == "error"
750+ assert errors[0]["status_since"] == "01 Apr 2021 05:14:13Z"
751+ assert errors[0]["status_msg"] == 'hook failed: "install"'
752+
753+ def test_AZ_parsing(self, linter, juju_status):
754+ """Test that the AZ parsing is working as expected."""
755+ # duplicate a AZ name so we have 2 AZs instead of the expected 3
756+ juju_status["machines"]["2"]["hardware"] = "availability-zone=rack-1"
757+ linter.do_lint(juju_status)
758+
759+ errors = linter.output_collector["errors"]
760+ assert len(errors) == 1
761+ assert errors[0]["id"] == "AZ-invalid-number"
762+ assert errors[0]["num_azs"] == 2
763+
764+ def test_AZ_balancing(self, linter, juju_status):
765+ """Test that applications are balanced across AZs."""
766+ # add an extra machine in an existing AZ
767+ juju_status["machines"].update(
768+ {
769+ "3": {
770+ "hardware": "availability-zone=rack-3",
771+ "juju-status": {"current": "started"},
772+ "machine-status": {"current": "running"},
773+ "modification-status": {"current": "applied"},
774+ }
775+ }
776+ )
777+ # add two more ubuntu units, but unbalanced (ubuntu/0 is in rack-1)
778+ juju_status["applications"]["ubuntu"]["units"].update(
779+ {
780+ "ubuntu/1": {
781+ "juju-status": {"current": "idle"},
782+ "machine": "2",
783+ "subordinates": {
784+ "ntp/1": {
785+ "juju-status": {"current": "idle"},
786+ "workload-status": {"current": "error"},
787+ }
788+ },
789+ "workload-status": {"current": "active"},
790+ },
791+ "ubuntu/2": {
792+ "juju-status": {"current": "idle"},
793+ "machine": "3",
794+ "subordinates": {
795+ "ntp/2": {
796+ "juju-status": {"current": "idle"},
797+ "workload-status": {"current": "error"},
798+ }
799+ },
800+ "workload-status": {"current": "active"},
801+ },
802+ }
803+ )
804+ linter.do_lint(juju_status)
805+
806+ errors = linter.output_collector["errors"]
807+ assert len(errors) == 1
808+ assert errors[0]["id"] == "AZ-unbalance"
809+ assert errors[0]["application"] == "ubuntu"
810+ assert errors[0]["num_units"] == 3
811+ assert errors[0]["az_map"] == "rack-1: 1, rack-2: 0, rack-3: 2"
812+
813+ def test_ops_charm_missing(self, linter, juju_status):
814+ """Test that missing ops mandatory charms are detected."""
815+ # add a new mandatory ops charm
816+ linter.lint_rules["operations mandatory"].append("telegraf")
817+ linter.do_lint(juju_status)
818+
819+ errors = linter.output_collector["errors"]
820+ assert len(errors) == 1
821+ assert errors[0]["id"] == "ops-charm-missing"
822+ assert errors[0]["charm"] == "telegraf"
823+
824+ def test_unrecognised_charm(self, linter, juju_status):
825+ """Test that unrecognised charms are detected."""
826+ # drop 'ubuntu' from the known charms
827+ linter.lint_rules["known charms"] = ["ntp"]
828+ linter.do_lint(juju_status)
829+
830+ errors = linter.output_collector["errors"]
831+ assert len(errors) == 1
832+ assert errors[0]["id"] == "unrecognised-charm"
833+ assert errors[0]["charm"] == "ubuntu"
834+
835+ def test_ops_subordinate_missing(self, linter, juju_status):
836+ """Test that missing ops subordinate charms are detected."""
837+ # drop the subordinate unit
838+ juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"]["subordinates"] = {}
839+ linter.do_lint(juju_status)
840+
841+ errors = linter.output_collector["errors"]
842+ assert len(errors) == 1
843+ assert errors[0]["id"] == "ops-subordinate-missing"
844+ assert errors[0]["principals"] == "ubuntu"
845+ assert errors[0]["subordinate"] == "ntp"
846+
847+ def test_subordinate_extraneous(self, linter, juju_status):
848+ """Test that extraneous subordinate charms are detected."""
849+ # this check triggers on subordinates on containers that should only
850+ # be present in hosts
851+ linter.lint_rules["subordinates"]["ntp"]["where"] = "host only"
852+ juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"][
853+ "machine"
854+ ] = "0/lxd/0"
855+ linter.do_lint(juju_status)
856+
857+ errors = linter.output_collector["errors"]
858+ assert len(errors) == 1
859+ assert errors[0]["id"] == "subordinate-extraneous"
860+ assert errors[0]["principals"] == "ubuntu"
861+ assert errors[0]["subordinate"] == "ntp"
862+
863+ def test_subordinate_duplicates(self, linter, juju_status):
864+ """Test that subordinate charms are not duplicated."""
865+ ntp0 = juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"][
866+ "subordinates"
867+ ]["ntp/0"]
868+ juju_status["applications"]["ubuntu"]["units"]["ubuntu/0"]["subordinates"][
869+ "ntp/1"
870+ ] = ntp0
871+ linter.do_lint(juju_status)
872+
873+ errors = linter.output_collector["errors"]
874+ assert len(errors) == 1
875+ assert errors[0]["id"] == "subordinate-duplicate"
876+ assert errors[0]["machines"] == "0"
877+ assert errors[0]["subordinate"] == "ntp"
878+
879+ def test_openstack_charm_missing(self, linter, juju_status):
880+ """Test that missing openstack mandatory charms are detected."""
881+ linter.cloud_type = "openstack"
882+ linter.lint_rules["openstack mandatory"] = ["keystone"]
883+ linter.lint_rules["operations openstack mandatory"] = ["ubuntu"]
884+ linter.do_lint(juju_status)
885+
886+ errors = linter.output_collector["errors"]
887+ assert len(errors) == 1
888+ assert errors[0]["id"] == "openstack-charm-missing"
889+ assert errors[0]["charm"] == "keystone"
890+
891+ def test_openstack_ops_charm_missing(self, linter, juju_status):
892+ """Test that missing openstack mandatory ops charms are detected."""
893+ linter.cloud_type = "openstack"
894+ linter.lint_rules["openstack mandatory"] = ["ubuntu"]
895+ linter.lint_rules["operations openstack mandatory"] = [
896+ "openstack-service-checks"
897+ ]
898+ linter.do_lint(juju_status)
899+
900+ errors = linter.output_collector["errors"]
901+ assert len(errors) == 1
902+ assert errors[0]["id"] == "openstack-ops-charm-missing"
903+ assert errors[0]["charm"] == "openstack-service-checks"
904+
905+ def test_openstack_charm_missing(self, linter, juju_status):
906+ """Test that missing kubernetes mandatory charms are detected."""
907+ linter.cloud_type = "kubernetes"
908+ linter.lint_rules["kubernetes mandatory"] = ["kubernetes-master"]
909+ linter.do_lint(juju_status)
910+
911+ errors = linter.output_collector["errors"]
912+ assert len(errors) == 1
913+ assert errors[0]["id"] == "kubernetes-charm-missing"
914+ assert errors[0]["charm"] == "kubernetes-master"
915+
916+ def test_config_eq(self, linter, juju_status):
917+ """Test the config condition 'eq'."""
918+ linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"eq": False}}}
919+ juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": True}
920+ linter.do_lint(juju_status)
921+
922+ errors = linter.output_collector["errors"]
923+ assert len(errors) == 1
924+ assert errors[0]["id"] == "config-eq-check"
925+ assert errors[0]["application"] == "ubuntu"
926+ assert errors[0]["rule"] == "fake-opt"
927+ assert errors[0]["expected_value"] == False
928+ assert errors[0]["actual_value"] == True
929+
930+ def test_config_gte(self, linter, juju_status):
931+ """Test the config condition 'gte'."""
932+ linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"gte": 3}}}
933+ juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": 0}
934+ linter.do_lint(juju_status)
935+
936+ errors = linter.output_collector["errors"]
937+ assert len(errors) == 1
938+ assert errors[0]["id"] == "config-gte-check"
939+ assert errors[0]["application"] == "ubuntu"
940+ assert errors[0]["rule"] == "fake-opt"
941+ assert errors[0]["expected_value"] == 3
942+ assert errors[0]["actual_value"] == 0
943+
944+ def test_config_isset_false(self, linter, juju_status):
945+ """Test the config condition 'isset' false."""
946+ linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": False}}}
947+ juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": 0}
948+ linter.do_lint(juju_status)
949+
950+ errors = linter.output_collector["errors"]
951+ assert len(errors) == 1
952+ assert errors[0]["id"] == "config-isset-check-false"
953+ assert errors[0]["application"] == "ubuntu"
954+ assert errors[0]["rule"] == "fake-opt"
955+ assert errors[0]["actual_value"] == 0
956+
957+ def test_config_isset_true(self, linter, juju_status):
958+ """Test the config condition 'isset' true."""
959+ linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"isset": True}}}
960+ juju_status["applications"]["ubuntu"]["options"] = {}
961+ linter.do_lint(juju_status)
962+
963+ errors = linter.output_collector["errors"]
964+ assert len(errors) == 1
965+ assert errors[0]["id"] == "config-isset-check-true"
966+ assert errors[0]["application"] == "ubuntu"
967+ assert errors[0]["rule"] == "fake-opt"

Subscribers

People subscribed via source and target branches