Merge ~jfguedez/juju-lint:feature/add-json into juju-lint:master
- Git
- lp:~jfguedez/juju-lint
- feature/add-json
- Merge into master
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) |
Related bugs: |
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:
{
"
"id": "ops-subordinat
"message": "Subordinate 'hw-health' is missing for application(s): 'ubuntu'",
"principals": "ubuntu",
"
"tags": [
"missing",
"ops",
"charm",
]
},
collected 20 items
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
tests/test_
=======
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
🤖 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.
Linda Guo (lihuiguo) wrote : | # |
more unit tests were added. LGTM
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change has no commit message, setting status to needs review.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 76ca869ab423f56
Preview Diff
1 | diff --git a/jujulint/cli.py b/jujulint/cli.py |
2 | index 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) |
39 | diff --git a/jujulint/config.py b/jujulint/config.py |
40 | index 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) |
57 | diff --git a/jujulint/lint.py b/jujulint/lint.py |
58 | index 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) |
577 | diff --git a/tests/conftest.py b/tests/conftest.py |
578 | index 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 | + } |
669 | diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py |
670 | index 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" |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.