Merge ~jfguedez/juju-lint:bug/1943222 into juju-lint:master

Proposed by Jose Guedez
Status: Merged
Approved by: James Troup
Approved revision: 1e13ff13f087c1f7559cf9550ae0e51909bf9e4e
Merged at revision: 76358c531368f9ff72ff4b2772eec7ced0378a2b
Proposed branch: ~jfguedez/juju-lint:bug/1943222
Merge into: juju-lint:master
Diff against target: 412 lines (+151/-111)
4 files modified
contrib/canonical-rules.yaml (+3/-3)
jujulint/lint.py (+126/-103)
tests/test_jujulint.py (+20/-5)
tox.ini (+2/-0)
Reviewer Review Type Date Requested Status
Juju Lint maintainers Pending
Review via email: mp+408463@code.launchpad.net

Commit message

Add 'neq' check for charm configuration

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 :

Change successfully merged at revision 76358c531368f9ff72ff4b2772eec7ced0378a2b

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/contrib/canonical-rules.yaml b/contrib/canonical-rules.yaml
2index fcdfdaa..556c84d 100644
3--- a/contrib/canonical-rules.yaml
4+++ b/contrib/canonical-rules.yaml
5@@ -24,7 +24,7 @@ openstack config:
6 eq: 9000
7 nova-compute:
8 cpu-model:
9- isset: true
10+ neq: ""
11 ceph-osd-replication-count:
12 eq: 3
13 percona-cluster:
14@@ -59,9 +59,9 @@ config:
15 eq: false
16 nrpe:
17 lacp_bonds:
18- isset: true
19+ neq: ""
20 netlinks:
21- isset: true
22+ neq: ""
23 landscape-client:
24 disable-unattended-upgrades:
25 eq: true
26diff --git a/jujulint/lint.py b/jujulint/lint.py
27index a8ae702..56a11b6 100755
28--- a/jujulint/lint.py
29+++ b/jujulint/lint.py
30@@ -32,12 +32,29 @@ import attr
31 from jujulint.util import flatten_list, is_container, extract_charm_name
32 from jujulint.logging import Logger
33
34+VALID_CONFIG_CHECKS = ("isset", "eq", "neq", "gte")
35+
36+# Generic named tuple to represent the binary config operators (eq,neq,gte)
37+ConfigOperator = collections.namedtuple(
38+ "ConfigOperator", "name repr check error_template"
39+)
40+
41 # TODO:
42-# - tests
43 # - missing relations for mandatory subordinates
44 # - info mode, e.g. num of machines, version (e.g. look at ceph), architecture
45
46
47+def helper_operator_eq_check(check_value, actual_value):
48+ """Perform the actual equality check for the eq/neq rules."""
49+ match = False
50+ try:
51+ match = re.match(re.compile(str(check_value)), str(actual_value))
52+ except re.error:
53+ match = check_value == actual_value
54+
55+ return match
56+
57+
58 @attrs
59 class ModelInfo(object):
60 """Represent information obtained from juju status data."""
61@@ -183,59 +200,12 @@ class Linter:
62
63 return _int * conv[val[-1].lower()]
64
65- def gte(self, name, check_value, rule, config):
66- """Check if value is greater than or equal to the check value."""
67- if rule in config:
68- current = self.atoi(config[rule])
69- expected = self.atoi(check_value)
70- if current >= expected:
71- self.logger.debug(
72- "[{}] [{}/{}] (PASS) Application {} has config for {} which is >= {}: {}.".format(
73- self.cloud_name,
74- self.controller_name,
75- self.model_name,
76- name,
77- rule,
78- check_value,
79- config[rule],
80- )
81- )
82- return True
83-
84- actual_value = config[rule]
85- self.handle_error(
86- {
87- "id": "config-gte-check",
88- "tags": ["config", "gte"],
89- "description": "Checks for config condition 'gte'",
90- "application": name,
91- "rule": rule,
92- "expected_value": check_value,
93- "actual_value": actual_value,
94- "message": "Application {} has config for {} which is less than {}: {}.".format(
95- name, rule, check_value, actual_value
96- ),
97- }
98- )
99- return False
100- self.logger.warn(
101- "[{}] [{}/{}] When checking if application {} has no config for {}, can't determine if >= than {}.".format(
102- self.cloud_name,
103- self.controller_name,
104- self.model_name,
105- name,
106- rule,
107- check_value,
108- )
109- )
110- return False
111-
112 def isset(self, name, check_value, rule, config):
113 """Check if value is set per rule constraints."""
114 if rule in config:
115 if check_value is True:
116 self.logger.debug(
117- "[{}] [{}/{}] (PASS) Application {} correctly has manual config for {}: {}.".format(
118+ "[{}] [{}/{}] (PASS) Application {} correctly has config for '{}': {}.".format(
119 self.cloud_name,
120 self.controller_name,
121 self.model_name,
122@@ -254,15 +224,15 @@ class Linter:
123 "application": name,
124 "rule": rule,
125 "actual_value": actual_value,
126- "message": "Application {} has manual config for {}: {}.".format(
127+ "message": "Application {} has config for {}: {}.".format(
128 name, rule, actual_value
129 ),
130 }
131 )
132 return False
133- if check_value is False:
134+ elif check_value is False:
135 self.logger.debug(
136- "[{}] [{}/{}] (PASS) Application {} is correctly using default config for {}.".format(
137+ "[{}] [{}/{}] (PASS) Application {} correctly had no config for '{}'.".format(
138 self.cloud_name,
139 self.controller_name,
140 self.model_name,
141@@ -278,61 +248,118 @@ class Linter:
142 "description": "Checks for config condition 'isset' true",
143 "application": name,
144 "rule": rule,
145- "message": "Application {} has no manual config for {}.".format(
146- name, rule
147- ),
148+ "message": "Application {} has no config for {}.".format(name, rule),
149 }
150 )
151 return False
152
153- def eq(self, name, check_value, rule, config):
154- """Check if value is matches the provided value or regex, autodetecting regex."""
155- if rule in config:
156- match = False
157- try:
158- match = re.match(re.compile(str(check_value)), str(config[rule]))
159- except re.error:
160- match = check_value == config[rule]
161- if match:
162- self.logger.debug(
163- "[{}] [{}/{}] Application {} has correct setting for {}: Expected {}, got {}.".format(
164- self.cloud_name,
165- self.controller_name,
166- self.model_name,
167- name,
168- rule,
169- check_value,
170- config[rule],
171- )
172+ def eq(self, app_name, check_value, config_key, app_config):
173+ """Check if value matches the provided value or regex, autodetecting regex."""
174+ operator = ConfigOperator(
175+ name="eq",
176+ repr="==",
177+ check=helper_operator_eq_check,
178+ error_template="Application {} has incorrect setting for '{}': Expected {}, got {}",
179+ )
180+
181+ return self.check_config_generic(
182+ operator, app_name, check_value, config_key, app_config
183+ )
184+
185+ def neq(self, app_name, check_value, config_key, app_config):
186+ """Check if value does not match a the config."""
187+ operator = ConfigOperator(
188+ name="neq",
189+ repr="!=",
190+ check=lambda check_value, actual_value: not helper_operator_eq_check(
191+ check_value, actual_value
192+ ),
193+ error_template="Application {} has incorrect setting for '{}': Should not be {}",
194+ )
195+
196+ return self.check_config_generic(
197+ operator, app_name, check_value, config_key, app_config
198+ )
199+
200+ def gte(self, app_name, check_value, config_key, app_config):
201+ """Check if value is greater than or equal to the check value."""
202+
203+ def operator_gte_check(check_value, actual_value):
204+ """Perform the actual gte check."""
205+ current = self.atoi(actual_value)
206+ expected = self.atoi(check_value)
207+ return current >= expected
208+
209+ operator = ConfigOperator(
210+ name="gte",
211+ repr=">=",
212+ check=operator_gte_check,
213+ error_template="Application {} has config for '{}' which is less than {}: {}",
214+ )
215+
216+ return self.check_config_generic(
217+ operator, app_name, check_value, config_key, app_config
218+ )
219+
220+ def check_config_generic(
221+ self, operator, app_name, check_value, config_key, app_config
222+ ):
223+ """Apply the provided config operator to the configuration."""
224+ model_header = "[{}] [{}/{}]".format(
225+ self.cloud_name,
226+ self.controller_name,
227+ self.model_name,
228+ )
229+
230+ # First check if the config key is present
231+ if config_key not in app_config:
232+ self.logger.warn(
233+ "{} Application {} has no config for '{}', cannot determine if {} {}.".format(
234+ model_header,
235+ app_name,
236+ config_key,
237+ operator.repr,
238+ repr(check_value),
239 )
240- return True
241- actual_value = config[rule]
242+ )
243+ return False
244+
245+ actual_value = app_config[config_key]
246+
247+ # Apply the check callable and handle the possible cases
248+ if operator.check(check_value, actual_value):
249+ self.logger.debug(
250+ "{} Application {} has a valid config for '{}': {} ({} {})".format(
251+ model_header,
252+ app_name,
253+ config_key,
254+ repr(check_value),
255+ operator.repr,
256+ repr(actual_value),
257+ )
258+ )
259+ return True
260+ else:
261 self.handle_error(
262 {
263- "id": "config-eq-check",
264- "tags": ["config", "eq"],
265- "description": "Checks for config condition 'eq'",
266- "application": name,
267- "rule": rule,
268+ "id": "config-{}-check".format(operator.name),
269+ "tags": ["config", operator.name],
270+ "description": "Checks for config condition '{}'".format(
271+ operator.name
272+ ),
273+ "application": app_name,
274+ "rule": config_key,
275 "expected_value": check_value,
276 "actual_value": actual_value,
277- "message": "Application {} has incorrect setting for {}: Expected {}, got {}.".format(
278- name, rule, check_value, actual_value
279+ "message": operator.error_template.format(
280+ app_name,
281+ config_key,
282+ repr(check_value),
283+ repr(actual_value),
284 ),
285 }
286 )
287- return False
288- else:
289- self.logger.warn(
290- "[{}] [{}/{}] When checking if application {} has no config for {}, can't determine if == {}.".format(
291- self.cloud_name,
292- self.controller_name,
293- self.model_name,
294- name,
295- rule,
296- check_value,
297- )
298- )
299+ return False
300
301 def check_config(self, name, config, rules):
302 """Check application against provided rules."""
303@@ -344,12 +371,10 @@ class Linter:
304 )
305 )
306 for check_op, check_value in rules[rule].items():
307- if check_op == "isset":
308- self.isset(name, check_value, rule, config)
309- elif check_op == "eq":
310- self.eq(name, check_value, rule, config)
311- elif check_op == "gte":
312- self.gte(name, check_value, rule, config)
313+ # check_op should be the operator name, e.g. (eq, neq, gte, isset)
314+ if check_op in VALID_CONFIG_CHECKS:
315+ check_method = getattr(self, check_op)
316+ check_method(name, check_value, rule, config)
317 else:
318 self.logger.warn(
319 "[{}] [{}/{}] Application {} has unknown check operation for {}: {}.".format(
320@@ -740,7 +765,6 @@ class Linter:
321 }
322 )
323
324-
325 def results(self):
326 """Provide results of the linting process."""
327 if self.model.missing_subs:
328@@ -1080,7 +1104,6 @@ class Linter:
329
330 def handle_error(self, error):
331 """Collect an error and add it to the collector."""
332-
333 self.logger.error(
334 "[{}] [{}/{}] {}.".format(
335 self.cloud_name, self.controller_name, self.model_name, error["message"]
336diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
337index 35c4bbe..6aa6ab4 100644
338--- a/tests/test_jujulint.py
339+++ b/tests/test_jujulint.py
340@@ -2,7 +2,6 @@
341 """Tests for jujulint."""
342
343 import pytest
344-import jujulint
345
346
347 def test_flatten_list(utils):
348@@ -37,6 +36,8 @@ def test_map_charms(linter, utils):
349
350
351 class TestLinter:
352+ """Test the main Linter class."""
353+
354 def test_minimal_rules(self, linter, juju_status):
355 """Test that the base rules/status have no errors."""
356 linter.do_lint(juju_status)
357@@ -90,7 +91,7 @@ class TestLinter:
358 assert errors[0]["status_since"] == "01 Apr 2021 05:14:13Z"
359 assert errors[0]["status_msg"] == 'hook failed: "install"'
360
361- def test_AZ_parsing(self, linter, juju_status):
362+ def test_az_parsing(self, linter, juju_status):
363 """Test that the AZ parsing is working as expected."""
364 # duplicate a AZ name so we have 2 AZs instead of the expected 3
365 juju_status["machines"]["2"]["hardware"] = "availability-zone=rack-1"
366@@ -101,7 +102,7 @@ class TestLinter:
367 assert errors[0]["id"] == "AZ-invalid-number"
368 assert errors[0]["num_azs"] == 2
369
370- def test_AZ_balancing(self, linter, juju_status):
371+ def test_az_balancing(self, linter, juju_status):
372 """Test that applications are balanced across AZs."""
373 # add an extra machine in an existing AZ
374 juju_status["machines"].update(
375@@ -277,8 +278,22 @@ class TestLinter:
376 assert errors[0]["id"] == "config-eq-check"
377 assert errors[0]["application"] == "ubuntu"
378 assert errors[0]["rule"] == "fake-opt"
379- assert errors[0]["expected_value"] == False
380- assert errors[0]["actual_value"] == True
381+ assert errors[0]["expected_value"] is False
382+ assert errors[0]["actual_value"] is True
383+
384+ def test_config_neq(self, linter, juju_status):
385+ """Test the config condition 'neq'."""
386+ linter.lint_rules["config"] = {"ubuntu": {"fake-opt": {"neq": False}}}
387+ juju_status["applications"]["ubuntu"]["options"] = {"fake-opt": False}
388+ linter.do_lint(juju_status)
389+
390+ errors = linter.output_collector["errors"]
391+ assert len(errors) == 1
392+ assert errors[0]["id"] == "config-neq-check"
393+ assert errors[0]["application"] == "ubuntu"
394+ assert errors[0]["rule"] == "fake-opt"
395+ assert errors[0]["expected_value"] is False
396+ assert errors[0]["actual_value"] is False
397
398 def test_config_gte(self, linter, juju_status):
399 """Test the config condition 'gte'."""
400diff --git a/tox.ini b/tox.ini
401index cf22f0f..3e9dbdf 100644
402--- a/tox.ini
403+++ b/tox.ini
404@@ -3,6 +3,8 @@ exclude =
405 .git,
406 __pycache__,
407 .tox,
408+ .eggs,
409+ venv,
410 max-line-length = 120
411 max-complexity = 10
412 ignore = C901

Subscribers

People subscribed via source and target branches