Merge ~vultaire/juju-lint:lp1840814 into juju-lint:master

Proposed by Paul Goins
Status: Merged
Approved by: Andrea Ieri
Approved revision: efc307212a66b339ad5caeffd214d48e33d14182
Merged at revision: 8d75f2145d27bb1aa5cb4f4e93c7c82f2fa8b7f8
Proposed branch: ~vultaire/juju-lint:lp1840814
Merge into: juju-lint:master
Diff against target: 441 lines (+358/-1)
7 files modified
.gitignore (+3/-0)
README.md (+35/-1)
jujulint/check_spaces.py (+135/-0)
jujulint/cli.py (+4/-0)
jujulint/lint.py (+63/-0)
tests/requirements.txt (+1/-0)
tests/test_jujulint.py (+117/-0)
Reviewer Review Type Date Requested Status
Xav Paice (community) Approve
Linda Guo (community) Approve
James Troup (community) Approve
Review via email: mp+416577@code.launchpad.net

Commit message

Added relation space checking

Mismatched spaces will result in warnings by default. If appropriate
rules are set, these warnings can either be coerced to errors, or
marked as ignorable.

Original code of jujulint.check_spaces comes from
https://git.launchpad.net/~sbparke/+git/checkSpaces/tree/checkSpaces.py.

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
Paul Goins (vultaire) wrote :

In addition to code feedback, please provide feedback regarding whether the rule options make sense, whether the error/warning message should be reworded, etc.

Revision history for this message
James Troup (elmo) wrote :

a) please black-en in a followup MP
b) consider adding an example to the README as to why this matters (e.g. Ceph sending traffic over 1Gb links vs 25Gb links)

Other than that, this looks good to me, thanks!

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

LGTM

review: Approve
Revision history for this message
Xav Paice (xavpaice) wrote :

LGTM

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

Change successfully merged at revision 8d75f2145d27bb1aa5cb4f4e93c7c82f2fa8b7f8

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2index 1f49ca6..6b085ea 100644
3--- a/.gitignore
4+++ b/.gitignore
5@@ -28,3 +28,6 @@ stage/
6
7 # runtime
8 output/
9+
10+# venv
11+/venv
12diff --git a/README.md b/README.md
13index 9058da9..a99f87c 100644
14--- a/README.md
15+++ b/README.md
16@@ -62,7 +62,41 @@ Supported top-level options for your rules file:
17 4. `openstack [mandatory|optional|subordinate]`
18 5. `config` - application configuration auditing
19 6. `[openstack|kubernetes] config` - config auditing for specific cloud types.
20- 7. `!include <relative path>` - Extension to yaml to include files.
21+ 7. `space checks` - enforce/ignore checks for relation binding space
22+ mismatches.
23+ 8. `!include <relative path>` - Extension to yaml to include files.
24+
25+=== Space checks ===
26+
27+All relations defined within a bundle, except for cross-model relationships,
28+will be checked for mismatches of their space bindings.
29+
30+By default, mismatches are logged as warnings as they are not necessarily
31+critical problems. If applications can route to each other despite the
32+mismatch, there may be no real issue here, and it may be appropriate to ignore
33+certain issues. On the other hand, these mismatches may cause problems ranging
34+from impaired throughput due to using suboptimal interfaces to breakages due to
35+not being able to route between the related units.
36+
37+The following options are available to either log such mismatches as errors or
38+to ignore them entirely:
39+
40+ 1. `enforce endpoints` - A list of <charm>:<endpoint> strings. If a mismatch
41+ matches one of these endpoints, it will be flagged as an error.
42+ 2. `enforce relations` - A list of two-item <charm>:<endpoint> string lists,
43+ representing the two linked endpoints of a relation. If a mismatch
44+ matches one of these relations, it will be flagged as an error.
45+ 1. `ignore endpoints` - A list of <charm>:<endpoint> strings. If a mismatch
46+ matches one of these endpoints, it will be ignored.
47+ 1. `ignore relations` - A list of two-item <charm>:<endpoint> string lists,
48+ representing the two linked endpoints of a relation. If a mismatch
49+ matches one of these relations, it will be ignored.
50+
51+In the case of a mismatch matching both enforce and ignore rules, the enforce
52+rule will "win" and it will be flagged as an error.
53+
54+Note that all the above checks use charm names rather than application names
55+in their endpoint strings.
56
57 == License ==
58
59diff --git a/jujulint/check_spaces.py b/jujulint/check_spaces.py
60new file mode 100644
61index 0000000..e9c850b
62--- /dev/null
63+++ b/jujulint/check_spaces.py
64@@ -0,0 +1,135 @@
65+#!/usr/bin/python3
66+"""Checks for space mismatches between relation endpoints."""
67+
68+
69+class Relation:
70+ """Relation object."""
71+
72+ def __init__(self, endpoint1, endpoint2):
73+ """Object for representing relations."""
74+ self.endpoint1 = endpoint1
75+ self.endpoint2 = endpoint2
76+
77+ def __str__(self):
78+ """Stringify the object."""
79+ return "Relation({} - {})".format(self.endpoint1, self.endpoint2)
80+
81+ def __eq__(self, other):
82+ """Compare relations.
83+
84+ While Juju does define separate provider and requirer roles, we'll ignore
85+ those here.
86+ """
87+ return set([self.endpoint1, self.endpoint2]) == set([other.endpoint1, other.endpoint2])
88+
89+ @property
90+ def endpoints(self):
91+ """Return list of endpoints."""
92+ return [self.endpoint1, self.endpoint2]
93+
94+
95+class SpaceMismatch:
96+ """Object for representing relation space mismatches."""
97+
98+ def __init__(self, endpoint1, space1, endpoint2, space2):
99+ """Create the object."""
100+ self.endpoint1 = endpoint1
101+ self.endpoint2 = endpoint2
102+ self.space1 = space1
103+ self.space2 = space2
104+
105+ def __str__(self):
106+ """Stringify the object."""
107+ return "SpaceMismatch({} (space {}) != {} (space {}))".format(
108+ self.endpoint1, self.space1, self.endpoint2, self.space2)
109+
110+ @property
111+ def relation(self):
112+ """Return a relation object based upon application endpoints."""
113+ return Relation(self.endpoint1, self.endpoint2)
114+
115+ def get_charm_relation(self, app_to_charm_map):
116+ """Return a relation object, mapping applications to charms."""
117+ app1, endpoint1 = self.endpoint1.split(':')
118+ app2, endpoint2 = self.endpoint2.split(':')
119+ charm1 = app_to_charm_map[app1]
120+ charm2 = app_to_charm_map[app2]
121+ return Relation(":".join([charm1, endpoint1]), ":".join([charm2, endpoint2]))
122+
123+
124+def find_space_mismatches(parsed_yaml, debug=False):
125+ """Enumerate relations and detect space mismatches.
126+
127+ Returns a list of objects representing the mismatches.
128+
129+ """
130+ application_list = get_juju_applications(parsed_yaml)
131+ app_spaces = get_application_spaces(application_list, parsed_yaml)
132+
133+ if debug:
134+ print("APP_SPACES")
135+ for app, map in app_spaces.items():
136+ print(app)
137+ for key, value in map.items():
138+ print(" " + key + " " + value)
139+ print("\n")
140+
141+ relations_list = get_application_relations(parsed_yaml)
142+
143+ if debug:
144+ print("APP_RELATIONS")
145+ for relation in relations_list:
146+ print(relation)
147+ print("\n")
148+
149+ mismatches = []
150+
151+ for relation in relations_list:
152+ space1 = get_relation_space(relation.endpoint1, app_spaces)
153+ space2 = get_relation_space(relation.endpoint2, app_spaces)
154+ if space1 != space2:
155+ mismatch = SpaceMismatch(relation.endpoint1, space1, relation.endpoint2, space2)
156+ mismatches.append(mismatch)
157+
158+ if debug:
159+ print("\nHere are the mismatched relations\n")
160+ for mismatch in mismatches:
161+ print("Mismatch:", mismatch)
162+ return mismatches
163+
164+
165+def get_juju_applications(parsed_yaml):
166+ """Return a list of applications in the bundle."""
167+ return [name for name in parsed_yaml["applications"]]
168+
169+
170+def get_application_spaces(application_list, parsed_yaml):
171+ """Return a dictionary with app.binding=space mappings."""
172+ app_spaces = {}
173+ for app in application_list:
174+ app_spaces[app] = {}
175+ bindings = parsed_yaml["applications"][app]['bindings']
176+ for name, value in bindings.items():
177+ app_spaces[app][name] = value
178+ return app_spaces
179+
180+
181+def get_application_relations(parsed_yaml):
182+ """Return a list of relations extracted from the bundle."""
183+ relation_list = []
184+ for provider, requirer in parsed_yaml["relations"]:
185+ relation = Relation(provider, requirer)
186+ relation_list.append(relation)
187+ return relation_list
188+
189+
190+def get_relation_space(endpoint, app_spaces):
191+ """Get space for specified app and service."""
192+ app, service = endpoint.split(':')
193+ if app in app_spaces:
194+ if service not in app_spaces[app]:
195+ return app_spaces[app][""]
196+ else:
197+ return app_spaces[app][service]
198+ else:
199+ return "XModel"
200diff --git a/jujulint/cli.py b/jujulint/cli.py
201index aafe9a5..af5190a 100755
202--- a/jujulint/cli.py
203+++ b/jujulint/cli.py
204@@ -170,3 +170,7 @@ def main():
205 cli.audit_all()
206 else:
207 cli.usage()
208+
209+
210+if __name__ == "__main__":
211+ main()
212diff --git a/jujulint/lint.py b/jujulint/lint.py
213index a59d66a..c1aa3be 100755
214--- a/jujulint/lint.py
215+++ b/jujulint/lint.py
216@@ -35,6 +35,7 @@ from dateutil import relativedelta
217
218 import jujulint.util as utils
219 from jujulint.logging import Logger
220+from jujulint.check_spaces import find_space_mismatches, Relation
221
222 VALID_CONFIG_CHECKS = ("isset", "eq", "neq", "gte")
223
224@@ -676,6 +677,53 @@ class Linter:
225 }
226 )
227
228+ def check_spaces(self, parsed_yaml):
229+ """Check that relations end with the same endpoint."""
230+ space_checks = self.lint_rules.get("space checks", {})
231+ enforce_endpoints = space_checks.get("enforce endpoints", [])
232+ enforce_relations = [
233+ Relation(*relation)
234+ for relation in space_checks.get("enforce relations", [])]
235+ ignore_endpoints = space_checks.get("ignore endpoints", [])
236+ ignore_relations = [
237+ Relation(*relation)
238+ for relation in space_checks.get("ignore relations", [])]
239+
240+ mismatches = find_space_mismatches(parsed_yaml)
241+ for mismatch in mismatches:
242+ # By default: treat mismatches as warnings.
243+ # If we have a matching enforcement rule, treat as an error.
244+ # If we have a matching ignore rule, do not warn.
245+ # (Enforcement rules win over ignore rules.)
246+ error = False
247+ warning = True
248+ mismatch_relation = mismatch.get_charm_relation(self.model.app_to_charm)
249+
250+ for enforce_endpoint in enforce_endpoints:
251+ if enforce_endpoint in mismatch_relation.endpoints:
252+ error = True
253+ for ignore_endpoint in ignore_endpoints:
254+ if ignore_endpoint in mismatch_relation.endpoints:
255+ warning = False
256+ for enforce_relation in enforce_relations:
257+ if enforce_relation == mismatch_relation:
258+ error = True
259+ for ignore_relation in ignore_relations:
260+ if ignore_relation == mismatch_relation:
261+ warning = False
262+
263+ message = "Space binding mismatch: {}".format(mismatch)
264+ if error:
265+ self.handle_error({
266+ "id": "space-binding-mismatch",
267+ "tags": ["mismatch", "space", "binding"],
268+ "description": "Unhandled space binding mismatch",
269+ "message": message,
270+ })
271+ elif warning:
272+ # DEFAULT: not a critical error, so just warn
273+ self._log_with_header(message, level=logging.WARN)
274+
275 def results(self):
276 """Provide results of the linting process."""
277 if self.model.missing_subs:
278@@ -1028,6 +1076,21 @@ class Linter:
279 self.check_subs(parsed_yaml["machines"])
280 self.check_charms()
281
282+ if "relations" in parsed_yaml:
283+ # "bindings" *should* be in exported bundles, *unless* no custom bindings exist,
284+ # in which case "juju export-bundle" omits them.
285+ if "bindings" in list(parsed_yaml[applications].values())[0]:
286+ self.check_spaces(parsed_yaml)
287+ else:
288+ self._log_with_header(
289+ "Relations detected but custom bindings not found; "
290+ "skipping space binding checks."
291+ )
292+ else:
293+ self._log_with_header(
294+ "Bundle relations data not found; skipping space binding checks."
295+ )
296+
297 if "relations" not in parsed_yaml:
298 self.map_machines_to_az(parsed_yaml["machines"])
299 self.check_azs(parsed_yaml[applications])
300diff --git a/tests/requirements.txt b/tests/requirements.txt
301index c20bbcd..b40fd2a 100644
302--- a/tests/requirements.txt
303+++ b/tests/requirements.txt
304@@ -10,3 +10,4 @@ pyflakes
305 pytest
306 pytest-cov
307 pytest-html
308+pytest-mock
309\ No newline at end of file
310diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
311index 228af00..1ec6cdf 100644
312--- a/tests/test_jujulint.py
313+++ b/tests/test_jujulint.py
314@@ -1,6 +1,8 @@
315 #!/usr/bin/python3
316 """Tests for jujulint."""
317 from datetime import datetime, timezone
318+from unittest import mock
319+import logging
320
321 import pytest
322
323@@ -693,3 +695,118 @@ applications:
324 linter.filename = str(rules_path)
325 linter.read_rules()
326 assert linter.lint_rules == {"key": "value", "key-inc": "value2"}
327+
328+ check_spaces_example_bundle = {
329+ "applications": {
330+ "prometheus-app": {
331+ "bindings": {
332+ "target": "internal-space",
333+ },
334+ },
335+ "telegraf-app": {
336+ "bindings": {
337+ "prometheus-client": "external-space",
338+ },
339+ },
340+ },
341+ "relations": [
342+ ["telegraf-app:prometheus-client", "prometheus-app:target"],
343+ ],
344+ }
345+
346+ check_spaces_example_app_charm_map = {
347+ "prometheus-app": "prometheus",
348+ "telegraf-app": "telegraf",
349+ }
350+
351+ def test_check_spaces_detect_mismatches(self, linter, mocker):
352+ mock_log: mock.MagicMock = mocker.patch('jujulint.lint.Linter._log_with_header')
353+ linter.model.app_to_charm = self.check_spaces_example_app_charm_map
354+
355+ # Run the space check.
356+ # Based on the above bundle, we should have exactly one mismatch.
357+ linter.check_spaces(self.check_spaces_example_bundle)
358+
359+ # By default the mismatch should only trigger a warning, not an error.
360+ errors = linter.output_collector["errors"]
361+ assert len(errors) == 0
362+ assert mock_log.call_count == 1
363+ assert mock_log.mock_calls[0].kwargs['level'] == logging.WARN
364+ assert mock_log.mock_calls[0].args[0] == (
365+ 'Space binding mismatch: SpaceMismatch(telegraf-app:prometheus-client '
366+ '(space external-space) != prometheus-app:target (space internal-space))'
367+ )
368+
369+ def test_check_spaces_enforce_endpoints(self, linter):
370+ linter.model.app_to_charm = self.check_spaces_example_app_charm_map
371+
372+ # Run the space check with prometheus:target endpoint enforced.
373+ # This should generate an error.
374+ linter.lint_rules["space checks"] = {"enforce endpoints": ["prometheus:target"]}
375+ linter.check_spaces(self.check_spaces_example_bundle)
376+ errors = linter.output_collector["errors"]
377+ assert len(errors) == 1
378+
379+ # Enforce the opposite end of the relation.
380+ # This should also generate an error.
381+ linter.lint_rules["space checks"] = {"enforce endpoints": ["telegraf:prometheus-client"]}
382+ linter.check_spaces(self.check_spaces_example_bundle)
383+ errors = linter.output_collector["errors"]
384+ assert len(errors) == 2
385+
386+ def test_check_spaces_enforce_relations(self, linter):
387+ linter.model.app_to_charm = self.check_spaces_example_app_charm_map
388+
389+ # Run the space check with prometheus:target endpoint enforced.
390+ # This should generate an error.
391+ linter.lint_rules["space checks"] = {"enforce relations": [["prometheus:target", "telegraf:prometheus-client"]]}
392+ linter.check_spaces(self.check_spaces_example_bundle)
393+ errors = linter.output_collector["errors"]
394+ assert len(errors) == 1
395+
396+ # Reverse the relation's definition order.
397+ # This should work the same way and also generate an error.
398+ linter.lint_rules["space checks"] = {"enforce relations": [["telegraf:prometheus-client", "prometheus:target"]]}
399+ linter.check_spaces(self.check_spaces_example_bundle)
400+ errors = linter.output_collector["errors"]
401+ assert len(errors) == 2
402+
403+ def test_check_spaces_ignore_endpoints(self, linter, mocker):
404+ mock_log: mock.MagicMock = mocker.patch('jujulint.lint.Linter._log_with_header')
405+ linter.model.app_to_charm = self.check_spaces_example_app_charm_map
406+
407+ # Run the space check with prometheus:target endpoint ignored.
408+ # This should generate an error.
409+ linter.lint_rules["space checks"] = {"ignore endpoints": ["prometheus:target"]}
410+ linter.check_spaces(self.check_spaces_example_bundle)
411+ errors = linter.output_collector["errors"]
412+ assert len(errors) == 0
413+ assert mock_log.call_count == 0
414+
415+ # Enforce the opposite end of the relation.
416+ # This should also generate an error.
417+ linter.lint_rules["space checks"] = {"ignore endpoints": ["telegraf:prometheus-client"]}
418+ linter.check_spaces(self.check_spaces_example_bundle)
419+ errors = linter.output_collector["errors"]
420+ assert len(errors) == 0
421+ assert mock_log.call_count == 0
422+
423+ def test_check_spaces_ignore_relations(self, linter, mocker):
424+ mock_log: mock.MagicMock = mocker.patch('jujulint.lint.Linter._log_with_header')
425+ linter.model.app_to_charm = self.check_spaces_example_app_charm_map
426+
427+ # Run the space check with prometheus:target endpoint ignored.
428+ # This should neither generate an error nor a warning.
429+ linter.lint_rules["space checks"] = {"ignore relations": [["prometheus:target", "telegraf:prometheus-client"]]}
430+ linter.check_spaces(self.check_spaces_example_bundle)
431+ errors = linter.output_collector["errors"]
432+ assert len(errors) == 0
433+ assert mock_log.call_count == 0
434+
435+ # Reverse the relation's definition order.
436+ # This should work the same way and also not generate errors/warnings.
437+ linter.lint_rules["space checks"] = {"ignore relations": [["telegraf:prometheus-client", "prometheus:target"]]}
438+ linter.check_spaces(self.check_spaces_example_bundle)
439+ errors = linter.output_collector["errors"]
440+ assert len(errors) == 0
441+ assert mock_log.call_count == 0

Subscribers

People subscribed via source and target branches