Merge ~vultaire/juju-lint:lp1840814 into juju-lint:master
- Git
- lp:~vultaire/juju-lint
- lp1840814
- Merge into master
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) |
Related bugs: |
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.
https:/
Description of the change
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
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.
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!
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 8d75f2145d27bb1
Preview Diff
1 | diff --git a/.gitignore b/.gitignore |
2 | index 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 |
12 | diff --git a/README.md b/README.md |
13 | index 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 | |
59 | diff --git a/jujulint/check_spaces.py b/jujulint/check_spaces.py |
60 | new file mode 100644 |
61 | index 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" |
200 | diff --git a/jujulint/cli.py b/jujulint/cli.py |
201 | index 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() |
212 | diff --git a/jujulint/lint.py b/jujulint/lint.py |
213 | index 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]) |
300 | diff --git a/tests/requirements.txt b/tests/requirements.txt |
301 | index 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 |
310 | diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py |
311 | index 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 |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.