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
diff --git a/.gitignore b/.gitignore
index 1f49ca6..6b085ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,6 @@ stage/
2828
29# runtime29# runtime
30output/30output/
31
32# venv
33/venv
diff --git a/README.md b/README.md
index 9058da9..a99f87c 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,41 @@ Supported top-level options for your rules file:
62 4. `openstack [mandatory|optional|subordinate]`62 4. `openstack [mandatory|optional|subordinate]`
63 5. `config` - application configuration auditing63 5. `config` - application configuration auditing
64 6. `[openstack|kubernetes] config` - config auditing for specific cloud types.64 6. `[openstack|kubernetes] config` - config auditing for specific cloud types.
65 7. `!include <relative path>` - Extension to yaml to include files.65 7. `space checks` - enforce/ignore checks for relation binding space
66 mismatches.
67 8. `!include <relative path>` - Extension to yaml to include files.
68
69=== Space checks ===
70
71All relations defined within a bundle, except for cross-model relationships,
72will be checked for mismatches of their space bindings.
73
74By default, mismatches are logged as warnings as they are not necessarily
75critical problems. If applications can route to each other despite the
76mismatch, there may be no real issue here, and it may be appropriate to ignore
77certain issues. On the other hand, these mismatches may cause problems ranging
78from impaired throughput due to using suboptimal interfaces to breakages due to
79not being able to route between the related units.
80
81The following options are available to either log such mismatches as errors or
82to ignore them entirely:
83
84 1. `enforce endpoints` - A list of <charm>:<endpoint> strings. If a mismatch
85 matches one of these endpoints, it will be flagged as an error.
86 2. `enforce relations` - A list of two-item <charm>:<endpoint> string lists,
87 representing the two linked endpoints of a relation. If a mismatch
88 matches one of these relations, it will be flagged as an error.
89 1. `ignore endpoints` - A list of <charm>:<endpoint> strings. If a mismatch
90 matches one of these endpoints, it will be ignored.
91 1. `ignore relations` - A list of two-item <charm>:<endpoint> string lists,
92 representing the two linked endpoints of a relation. If a mismatch
93 matches one of these relations, it will be ignored.
94
95In the case of a mismatch matching both enforce and ignore rules, the enforce
96rule will "win" and it will be flagged as an error.
97
98Note that all the above checks use charm names rather than application names
99in their endpoint strings.
66100
67== License ==101== License ==
68102
diff --git a/jujulint/check_spaces.py b/jujulint/check_spaces.py
69new file mode 100644103new file mode 100644
index 0000000..e9c850b
--- /dev/null
+++ b/jujulint/check_spaces.py
@@ -0,0 +1,135 @@
1#!/usr/bin/python3
2"""Checks for space mismatches between relation endpoints."""
3
4
5class Relation:
6 """Relation object."""
7
8 def __init__(self, endpoint1, endpoint2):
9 """Object for representing relations."""
10 self.endpoint1 = endpoint1
11 self.endpoint2 = endpoint2
12
13 def __str__(self):
14 """Stringify the object."""
15 return "Relation({} - {})".format(self.endpoint1, self.endpoint2)
16
17 def __eq__(self, other):
18 """Compare relations.
19
20 While Juju does define separate provider and requirer roles, we'll ignore
21 those here.
22 """
23 return set([self.endpoint1, self.endpoint2]) == set([other.endpoint1, other.endpoint2])
24
25 @property
26 def endpoints(self):
27 """Return list of endpoints."""
28 return [self.endpoint1, self.endpoint2]
29
30
31class SpaceMismatch:
32 """Object for representing relation space mismatches."""
33
34 def __init__(self, endpoint1, space1, endpoint2, space2):
35 """Create the object."""
36 self.endpoint1 = endpoint1
37 self.endpoint2 = endpoint2
38 self.space1 = space1
39 self.space2 = space2
40
41 def __str__(self):
42 """Stringify the object."""
43 return "SpaceMismatch({} (space {}) != {} (space {}))".format(
44 self.endpoint1, self.space1, self.endpoint2, self.space2)
45
46 @property
47 def relation(self):
48 """Return a relation object based upon application endpoints."""
49 return Relation(self.endpoint1, self.endpoint2)
50
51 def get_charm_relation(self, app_to_charm_map):
52 """Return a relation object, mapping applications to charms."""
53 app1, endpoint1 = self.endpoint1.split(':')
54 app2, endpoint2 = self.endpoint2.split(':')
55 charm1 = app_to_charm_map[app1]
56 charm2 = app_to_charm_map[app2]
57 return Relation(":".join([charm1, endpoint1]), ":".join([charm2, endpoint2]))
58
59
60def find_space_mismatches(parsed_yaml, debug=False):
61 """Enumerate relations and detect space mismatches.
62
63 Returns a list of objects representing the mismatches.
64
65 """
66 application_list = get_juju_applications(parsed_yaml)
67 app_spaces = get_application_spaces(application_list, parsed_yaml)
68
69 if debug:
70 print("APP_SPACES")
71 for app, map in app_spaces.items():
72 print(app)
73 for key, value in map.items():
74 print(" " + key + " " + value)
75 print("\n")
76
77 relations_list = get_application_relations(parsed_yaml)
78
79 if debug:
80 print("APP_RELATIONS")
81 for relation in relations_list:
82 print(relation)
83 print("\n")
84
85 mismatches = []
86
87 for relation in relations_list:
88 space1 = get_relation_space(relation.endpoint1, app_spaces)
89 space2 = get_relation_space(relation.endpoint2, app_spaces)
90 if space1 != space2:
91 mismatch = SpaceMismatch(relation.endpoint1, space1, relation.endpoint2, space2)
92 mismatches.append(mismatch)
93
94 if debug:
95 print("\nHere are the mismatched relations\n")
96 for mismatch in mismatches:
97 print("Mismatch:", mismatch)
98 return mismatches
99
100
101def get_juju_applications(parsed_yaml):
102 """Return a list of applications in the bundle."""
103 return [name for name in parsed_yaml["applications"]]
104
105
106def get_application_spaces(application_list, parsed_yaml):
107 """Return a dictionary with app.binding=space mappings."""
108 app_spaces = {}
109 for app in application_list:
110 app_spaces[app] = {}
111 bindings = parsed_yaml["applications"][app]['bindings']
112 for name, value in bindings.items():
113 app_spaces[app][name] = value
114 return app_spaces
115
116
117def get_application_relations(parsed_yaml):
118 """Return a list of relations extracted from the bundle."""
119 relation_list = []
120 for provider, requirer in parsed_yaml["relations"]:
121 relation = Relation(provider, requirer)
122 relation_list.append(relation)
123 return relation_list
124
125
126def get_relation_space(endpoint, app_spaces):
127 """Get space for specified app and service."""
128 app, service = endpoint.split(':')
129 if app in app_spaces:
130 if service not in app_spaces[app]:
131 return app_spaces[app][""]
132 else:
133 return app_spaces[app][service]
134 else:
135 return "XModel"
diff --git a/jujulint/cli.py b/jujulint/cli.py
index aafe9a5..af5190a 100755
--- a/jujulint/cli.py
+++ b/jujulint/cli.py
@@ -170,3 +170,7 @@ def main():
170 cli.audit_all()170 cli.audit_all()
171 else:171 else:
172 cli.usage()172 cli.usage()
173
174
175if __name__ == "__main__":
176 main()
diff --git a/jujulint/lint.py b/jujulint/lint.py
index a59d66a..c1aa3be 100755
--- a/jujulint/lint.py
+++ b/jujulint/lint.py
@@ -35,6 +35,7 @@ from dateutil import relativedelta
3535
36import jujulint.util as utils36import jujulint.util as utils
37from jujulint.logging import Logger37from jujulint.logging import Logger
38from jujulint.check_spaces import find_space_mismatches, Relation
3839
39VALID_CONFIG_CHECKS = ("isset", "eq", "neq", "gte")40VALID_CONFIG_CHECKS = ("isset", "eq", "neq", "gte")
4041
@@ -676,6 +677,53 @@ class Linter:
676 }677 }
677 )678 )
678679
680 def check_spaces(self, parsed_yaml):
681 """Check that relations end with the same endpoint."""
682 space_checks = self.lint_rules.get("space checks", {})
683 enforce_endpoints = space_checks.get("enforce endpoints", [])
684 enforce_relations = [
685 Relation(*relation)
686 for relation in space_checks.get("enforce relations", [])]
687 ignore_endpoints = space_checks.get("ignore endpoints", [])
688 ignore_relations = [
689 Relation(*relation)
690 for relation in space_checks.get("ignore relations", [])]
691
692 mismatches = find_space_mismatches(parsed_yaml)
693 for mismatch in mismatches:
694 # By default: treat mismatches as warnings.
695 # If we have a matching enforcement rule, treat as an error.
696 # If we have a matching ignore rule, do not warn.
697 # (Enforcement rules win over ignore rules.)
698 error = False
699 warning = True
700 mismatch_relation = mismatch.get_charm_relation(self.model.app_to_charm)
701
702 for enforce_endpoint in enforce_endpoints:
703 if enforce_endpoint in mismatch_relation.endpoints:
704 error = True
705 for ignore_endpoint in ignore_endpoints:
706 if ignore_endpoint in mismatch_relation.endpoints:
707 warning = False
708 for enforce_relation in enforce_relations:
709 if enforce_relation == mismatch_relation:
710 error = True
711 for ignore_relation in ignore_relations:
712 if ignore_relation == mismatch_relation:
713 warning = False
714
715 message = "Space binding mismatch: {}".format(mismatch)
716 if error:
717 self.handle_error({
718 "id": "space-binding-mismatch",
719 "tags": ["mismatch", "space", "binding"],
720 "description": "Unhandled space binding mismatch",
721 "message": message,
722 })
723 elif warning:
724 # DEFAULT: not a critical error, so just warn
725 self._log_with_header(message, level=logging.WARN)
726
679 def results(self):727 def results(self):
680 """Provide results of the linting process."""728 """Provide results of the linting process."""
681 if self.model.missing_subs:729 if self.model.missing_subs:
@@ -1028,6 +1076,21 @@ class Linter:
1028 self.check_subs(parsed_yaml["machines"])1076 self.check_subs(parsed_yaml["machines"])
1029 self.check_charms()1077 self.check_charms()
10301078
1079 if "relations" in parsed_yaml:
1080 # "bindings" *should* be in exported bundles, *unless* no custom bindings exist,
1081 # in which case "juju export-bundle" omits them.
1082 if "bindings" in list(parsed_yaml[applications].values())[0]:
1083 self.check_spaces(parsed_yaml)
1084 else:
1085 self._log_with_header(
1086 "Relations detected but custom bindings not found; "
1087 "skipping space binding checks."
1088 )
1089 else:
1090 self._log_with_header(
1091 "Bundle relations data not found; skipping space binding checks."
1092 )
1093
1031 if "relations" not in parsed_yaml:1094 if "relations" not in parsed_yaml:
1032 self.map_machines_to_az(parsed_yaml["machines"])1095 self.map_machines_to_az(parsed_yaml["machines"])
1033 self.check_azs(parsed_yaml[applications])1096 self.check_azs(parsed_yaml[applications])
diff --git a/tests/requirements.txt b/tests/requirements.txt
index c20bbcd..b40fd2a 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -10,3 +10,4 @@ pyflakes
10pytest10pytest
11pytest-cov11pytest-cov
12pytest-html12pytest-html
13pytest-mock
13\ No newline at end of file14\ No newline at end of file
diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
index 228af00..1ec6cdf 100644
--- a/tests/test_jujulint.py
+++ b/tests/test_jujulint.py
@@ -1,6 +1,8 @@
1#!/usr/bin/python31#!/usr/bin/python3
2"""Tests for jujulint."""2"""Tests for jujulint."""
3from datetime import datetime, timezone3from datetime import datetime, timezone
4from unittest import mock
5import logging
46
5import pytest7import pytest
68
@@ -693,3 +695,118 @@ applications:
693 linter.filename = str(rules_path)695 linter.filename = str(rules_path)
694 linter.read_rules()696 linter.read_rules()
695 assert linter.lint_rules == {"key": "value", "key-inc": "value2"}697 assert linter.lint_rules == {"key": "value", "key-inc": "value2"}
698
699 check_spaces_example_bundle = {
700 "applications": {
701 "prometheus-app": {
702 "bindings": {
703 "target": "internal-space",
704 },
705 },
706 "telegraf-app": {
707 "bindings": {
708 "prometheus-client": "external-space",
709 },
710 },
711 },
712 "relations": [
713 ["telegraf-app:prometheus-client", "prometheus-app:target"],
714 ],
715 }
716
717 check_spaces_example_app_charm_map = {
718 "prometheus-app": "prometheus",
719 "telegraf-app": "telegraf",
720 }
721
722 def test_check_spaces_detect_mismatches(self, linter, mocker):
723 mock_log: mock.MagicMock = mocker.patch('jujulint.lint.Linter._log_with_header')
724 linter.model.app_to_charm = self.check_spaces_example_app_charm_map
725
726 # Run the space check.
727 # Based on the above bundle, we should have exactly one mismatch.
728 linter.check_spaces(self.check_spaces_example_bundle)
729
730 # By default the mismatch should only trigger a warning, not an error.
731 errors = linter.output_collector["errors"]
732 assert len(errors) == 0
733 assert mock_log.call_count == 1
734 assert mock_log.mock_calls[0].kwargs['level'] == logging.WARN
735 assert mock_log.mock_calls[0].args[0] == (
736 'Space binding mismatch: SpaceMismatch(telegraf-app:prometheus-client '
737 '(space external-space) != prometheus-app:target (space internal-space))'
738 )
739
740 def test_check_spaces_enforce_endpoints(self, linter):
741 linter.model.app_to_charm = self.check_spaces_example_app_charm_map
742
743 # Run the space check with prometheus:target endpoint enforced.
744 # This should generate an error.
745 linter.lint_rules["space checks"] = {"enforce endpoints": ["prometheus:target"]}
746 linter.check_spaces(self.check_spaces_example_bundle)
747 errors = linter.output_collector["errors"]
748 assert len(errors) == 1
749
750 # Enforce the opposite end of the relation.
751 # This should also generate an error.
752 linter.lint_rules["space checks"] = {"enforce endpoints": ["telegraf:prometheus-client"]}
753 linter.check_spaces(self.check_spaces_example_bundle)
754 errors = linter.output_collector["errors"]
755 assert len(errors) == 2
756
757 def test_check_spaces_enforce_relations(self, linter):
758 linter.model.app_to_charm = self.check_spaces_example_app_charm_map
759
760 # Run the space check with prometheus:target endpoint enforced.
761 # This should generate an error.
762 linter.lint_rules["space checks"] = {"enforce relations": [["prometheus:target", "telegraf:prometheus-client"]]}
763 linter.check_spaces(self.check_spaces_example_bundle)
764 errors = linter.output_collector["errors"]
765 assert len(errors) == 1
766
767 # Reverse the relation's definition order.
768 # This should work the same way and also generate an error.
769 linter.lint_rules["space checks"] = {"enforce relations": [["telegraf:prometheus-client", "prometheus:target"]]}
770 linter.check_spaces(self.check_spaces_example_bundle)
771 errors = linter.output_collector["errors"]
772 assert len(errors) == 2
773
774 def test_check_spaces_ignore_endpoints(self, linter, mocker):
775 mock_log: mock.MagicMock = mocker.patch('jujulint.lint.Linter._log_with_header')
776 linter.model.app_to_charm = self.check_spaces_example_app_charm_map
777
778 # Run the space check with prometheus:target endpoint ignored.
779 # This should generate an error.
780 linter.lint_rules["space checks"] = {"ignore endpoints": ["prometheus:target"]}
781 linter.check_spaces(self.check_spaces_example_bundle)
782 errors = linter.output_collector["errors"]
783 assert len(errors) == 0
784 assert mock_log.call_count == 0
785
786 # Enforce the opposite end of the relation.
787 # This should also generate an error.
788 linter.lint_rules["space checks"] = {"ignore endpoints": ["telegraf:prometheus-client"]}
789 linter.check_spaces(self.check_spaces_example_bundle)
790 errors = linter.output_collector["errors"]
791 assert len(errors) == 0
792 assert mock_log.call_count == 0
793
794 def test_check_spaces_ignore_relations(self, linter, mocker):
795 mock_log: mock.MagicMock = mocker.patch('jujulint.lint.Linter._log_with_header')
796 linter.model.app_to_charm = self.check_spaces_example_app_charm_map
797
798 # Run the space check with prometheus:target endpoint ignored.
799 # This should neither generate an error nor a warning.
800 linter.lint_rules["space checks"] = {"ignore relations": [["prometheus:target", "telegraf:prometheus-client"]]}
801 linter.check_spaces(self.check_spaces_example_bundle)
802 errors = linter.output_collector["errors"]
803 assert len(errors) == 0
804 assert mock_log.call_count == 0
805
806 # Reverse the relation's definition order.
807 # This should work the same way and also not generate errors/warnings.
808 linter.lint_rules["space checks"] = {"ignore relations": [["telegraf:prometheus-client", "prometheus:target"]]}
809 linter.check_spaces(self.check_spaces_example_bundle)
810 errors = linter.output_collector["errors"]
811 assert len(errors) == 0
812 assert mock_log.call_count == 0

Subscribers

People subscribed via source and target branches