Merge ~gabrielcocenza/juju-lint:bug/1965762-B into juju-lint:master
- Git
- lp:~gabrielcocenza/juju-lint
- bug/1965762-B
- Merge into master
Proposed by
Gabriel Cocenza
Status: | Merged | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Approved by: | Martin Kalcok | ||||||||||||||||
Approved revision: | 93659c4993875748e8496d862696ed84b0d1cd42 | ||||||||||||||||
Merged at revision: | e41480d686387ef10326f9d54c26301297761f12 | ||||||||||||||||
Proposed branch: | ~gabrielcocenza/juju-lint:bug/1965762-B | ||||||||||||||||
Merge into: | juju-lint:master | ||||||||||||||||
Diff against target: |
868 lines (+735/-2) 10 files modified
contrib/includes/aggregator-kubernetes.yaml (+4/-0) contrib/includes/aggregator-openstack.yaml (+3/-0) contrib/includes/base.yaml (+7/-0) contrib/includes/kubernetes.yaml (+10/-0) jujulint/lint.py (+47/-0) jujulint/relations.py (+296/-0) tests/unit/conftest.py (+89/-0) tests/unit/test_jujulint.py (+70/-1) tests/unit/test_relations.py (+208/-0) tox.ini (+1/-1) |
||||||||||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Martin Kalcok (community) | Approve | ||
Eric Chen | Needs Information | ||
Review via email: mp+427991@code.launchpad.net |
Commit message
added new rule for checking relations.
Closes-Bug: #1965762, #1975548
Description of the change
Added a new rule to check relations.
pastebin of unit tests [0] after rebasing with 100% coverage
To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Revision history for this message
Martin Kalcok (martin-kalcok) wrote : | # |
Overall this change looks great. Thanks. I do have few comments/questions, see inline pls.
review:
Needs Information
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote : | # |
Thanks for your review Martin. Good points.
Revision history for this message
Eric Chen (eric-chen) wrote : | # |
LGTM.. But I would like to
1. Merge Martin's MP first.
2. Verify unit/func test in this MP and provide the log.
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote : | # |
pastebin of unit tests [0] after rebase, with 100% coverage
Revision history for this message
Eric Chen (eric-chen) wrote : | # |
What about lint/func test?
review:
Needs Information
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote : | # |
Rebased with last master commit with functional tests, squashed commits, results of lint, unit and functional tests [0]
Revision history for this message
Martin Kalcok (martin-kalcok) wrote : | # |
LGTM, thanks.
review:
Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision e41480d686387ef
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/contrib/includes/aggregator-kubernetes.yaml b/contrib/includes/aggregator-kubernetes.yaml |
2 | index 95c2099..cf4607f 100644 |
3 | --- a/contrib/includes/aggregator-kubernetes.yaml |
4 | +++ b/contrib/includes/aggregator-kubernetes.yaml |
5 | @@ -14,3 +14,7 @@ known charms: |
6 | - ubuntu |
7 | - *operations-charms |
8 | - *kubernetes-charms |
9 | + |
10 | +relations: |
11 | + - *relations-base-check |
12 | + - *kubernetes-relation-checks |
13 | diff --git a/contrib/includes/aggregator-openstack.yaml b/contrib/includes/aggregator-openstack.yaml |
14 | index 58a92fa..d8c1305 100644 |
15 | --- a/contrib/includes/aggregator-openstack.yaml |
16 | +++ b/contrib/includes/aggregator-openstack.yaml |
17 | @@ -20,3 +20,6 @@ known charms: |
18 | - ubuntu |
19 | - *openstack-charms |
20 | - *operations-charms |
21 | + |
22 | +relations: |
23 | + - *relations-base-check |
24 | diff --git a/contrib/includes/base.yaml b/contrib/includes/base.yaml |
25 | index 0e171e3..2ab4712 100644 |
26 | --- a/contrib/includes/base.yaml |
27 | +++ b/contrib/includes/base.yaml |
28 | @@ -43,3 +43,10 @@ subordinates: |
29 | where: all |
30 | local-users: |
31 | where: all or nothing |
32 | + |
33 | +# openstack and k8s should check nrpe relations. See LP#1965762 |
34 | +relations base check: &relations-base-check |
35 | + - charm: nrpe |
36 | + check: [ |
37 | + ["*:nrpe-external-master", "nrpe:nrpe-external-master"] |
38 | + ] |
39 | diff --git a/contrib/includes/kubernetes.yaml b/contrib/includes/kubernetes.yaml |
40 | index 2ade8ed..bbb5e90 100644 |
41 | --- a/contrib/includes/kubernetes.yaml |
42 | +++ b/contrib/includes/kubernetes.yaml |
43 | @@ -22,3 +22,13 @@ kubernetes optional charms: &kubernetes-optional-charms |
44 | - kubernetes-dashboard |
45 | - openstack-integrator |
46 | - vsphere-integrator |
47 | + |
48 | +# See LP#1975548 |
49 | +kubernetes relation checks: &kubernetes-relation-checks |
50 | + - charm: openstack-integrator |
51 | + check: [ |
52 | + ["kubernetes-control-plane:loadbalancer", "openstack-integrator:loadbalancer"] |
53 | + ] |
54 | + not-exist: [ |
55 | + ["kubernetes-control-plane:kube-api-endpoint", "kubernetes-worker:kube-api-endpoint"] |
56 | + ] |
57 | diff --git a/jujulint/lint.py b/jujulint/lint.py |
58 | index a931d55..937733e 100755 |
59 | --- a/jujulint/lint.py |
60 | +++ b/jujulint/lint.py |
61 | @@ -36,6 +36,7 @@ from dateutil import relativedelta |
62 | import jujulint.util as utils |
63 | from jujulint.check_spaces import Relation, find_space_mismatches |
64 | from jujulint.logging import Logger |
65 | +from jujulint.relations import RelationError, RelationsRulesBootStrap |
66 | |
67 | VALID_CONFIG_CHECKS = ("isset", "eq", "neq", "gte", "search") |
68 | |
69 | @@ -67,6 +68,7 @@ class ModelInfo(object): |
70 | charms = attrib(default=attr.Factory(set)) |
71 | cmr_apps = attrib(default=attr.Factory(set)) |
72 | app_to_charm = attrib(default=attr.Factory(dict)) |
73 | + charm_to_app = attrib(default=attr.Factory(dict)) |
74 | subs_on_machines = attrib(default=attr.Factory(dict)) |
75 | apps_on_machines = attrib(default=attr.Factory(dict)) |
76 | machines_to_az = attrib(default=attr.Factory(dict)) |
77 | @@ -611,6 +613,49 @@ class Linter: |
78 | if not self.model.extraneous_subs[sub]: |
79 | del self.model.extraneous_subs[sub] |
80 | |
81 | + def check_relations(self, applications): |
82 | + """Check the relations in the rules file. |
83 | + |
84 | + :param applications: applications present in the model |
85 | + :type applications: Dict |
86 | + """ |
87 | + if "relations" not in self.lint_rules: |
88 | + self._log_with_header("No relation rules found. Skipping relation checks") |
89 | + return |
90 | + try: |
91 | + relations_rules = RelationsRulesBootStrap( |
92 | + charm_to_app=self.model.charm_to_app, |
93 | + relations_rules=self.lint_rules["relations"], |
94 | + applications=applications, |
95 | + ).check() |
96 | + except RelationError as e: |
97 | + relations_rules = [] |
98 | + self._log_with_header(e.message, level=logging.ERROR) |
99 | + |
100 | + for rule in relations_rules: |
101 | + for endpoint, applications in rule.missing_relations.items(): |
102 | + if applications: |
103 | + self.handle_error( |
104 | + { |
105 | + "id": "missing-relations", |
106 | + "tags": ["relation", "missing"], |
107 | + "message": "Endpoint '{}' is missing relations with: {}".format( |
108 | + endpoint, applications |
109 | + ), |
110 | + } |
111 | + ) |
112 | + if rule.not_exist_error: |
113 | + for relation in rule.not_exist_error: |
114 | + self.handle_error( |
115 | + { |
116 | + "id": "relation-exist", |
117 | + "tags": ["relation", "exist"], |
118 | + "message": "Relation(s) {} should not exist.".format( |
119 | + relation |
120 | + ), |
121 | + } |
122 | + ) |
123 | + |
124 | def check_charms_ops_mandatory(self, charm): |
125 | """ |
126 | Check if a mandatory ops charms is present in the model. |
127 | @@ -928,6 +973,7 @@ class Linter: |
128 | charm_name = utils.extract_charm_name(applications[app]["charm"]) |
129 | self.model.charms.add(charm_name) |
130 | self.model.app_to_charm[app] = charm_name |
131 | + self.model.charm_to_app.setdefault(charm_name, set()).add(app) |
132 | else: |
133 | self.handle_error( |
134 | { |
135 | @@ -1205,6 +1251,7 @@ class Linter: |
136 | self.process_subordinates(parsed_yaml[applications][app], app) |
137 | |
138 | self.check_subs(parsed_yaml["machines"]) |
139 | + self.check_relations(parsed_yaml[applications]) |
140 | self.check_charms() |
141 | |
142 | if "relations" in parsed_yaml: |
143 | diff --git a/jujulint/relations.py b/jujulint/relations.py |
144 | new file mode 100644 |
145 | index 0000000..b6a39d2 |
146 | --- /dev/null |
147 | +++ b/jujulint/relations.py |
148 | @@ -0,0 +1,296 @@ |
149 | +#!/usr/bin/python3 |
150 | +"""Checks relations between applications.""" |
151 | +from logging import getLogger |
152 | +from typing import Any, Dict, List, Set, Tuple |
153 | + |
154 | +LOGGER = getLogger(__name__) |
155 | + |
156 | + |
157 | +class RelationError(Exception): |
158 | + """Special exception for relations errors.""" |
159 | + |
160 | + def __init__(self, msg: str): |
161 | + """Relation error message format. |
162 | + |
163 | + :param msg: Message of the exception found. |
164 | + :type msg: str |
165 | + """ |
166 | + self.message = f"{self.__class__.__name__}: {msg}" |
167 | + |
168 | + |
169 | +class RelationRule: |
170 | + """Object to check the relations rules.""" |
171 | + |
172 | + def __init__( |
173 | + self, |
174 | + charm_to_app: Set, |
175 | + applications: Dict[str, Any], |
176 | + charm: str, |
177 | + relations: List[List[str]], |
178 | + not_exist: List[List[str]], |
179 | + exception: Set, |
180 | + ): |
181 | + """Relation Rule object. |
182 | + |
183 | + :param charm_to_app: A charm can have more than one application name |
184 | + e.g:(nrpe-host and nrpe-container). This parameters contains all |
185 | + applications name of a given charm. |
186 | + :type charm_to_app: Set |
187 | + :param applications: All applications of the file passed to lint. |
188 | + :type applications: Dict[str, Any] |
189 | + :param charm: Name of the charm to check the relations rules. |
190 | + :type charm: str |
191 | + :param relations: Relations that should be checked of a given charm |
192 | + having the following format: |
193 | + [[<application>:<application_endpoint>, <application>:<application_endpoint>]] |
194 | + If <application> == "*" it will check the relation using the endpoint |
195 | + on all applications form the file passed. |
196 | + :type relations: List[List[str]] |
197 | + :param not_exist: Relation that should not exist in the presence of the |
198 | + relations passed to be checked. This parameter has the following format: |
199 | + [[<application>:<application_endpoint>, <application>:<application_endpoint>]] |
200 | + :type not_exist: List[List[str]], |
201 | + :param exception: Set of applications that the rule doesn't apply to. |
202 | + :type exception: Set |
203 | + """ |
204 | + self.charm_to_app = charm_to_app |
205 | + self.applications = applications |
206 | + self.charm = charm |
207 | + self.relations = relations |
208 | + self.not_exist = not_exist |
209 | + self.exception = exception |
210 | + # remove all possible app names from the subordinate app to not check itself |
211 | + self.apps_to_check = set(self.applications.keys()) - self.charm_to_app |
212 | + self.missing_relations = dict() |
213 | + self.not_exist_error = list() |
214 | + |
215 | + @property |
216 | + def relations(self) -> List[List[str]]: |
217 | + """Relations to be checked in a charm. |
218 | + |
219 | + :return: List of list containing the app and endpoint to check, |
220 | + with the following format: [[app_to_check, endpoint_to_check]] |
221 | + :rtype: List[List[str]] |
222 | + """ |
223 | + return self._relations |
224 | + |
225 | + @relations.setter |
226 | + def relations(self, raw_relations_rules: List[List[str]]) -> None: |
227 | + """Relation setter of a rule. |
228 | + |
229 | + :param raw_relations_rules: Relations that should be checked of a given charm |
230 | + having the following format: |
231 | + [[<application_1>:<endpoint>], [<application_2>:<endpoint>]] |
232 | + :type raw_relations_rules: List[List[str]] |
233 | + :raises RelationError: Raises RelationError if not in expected format. |
234 | + """ |
235 | + self._relations = [] |
236 | + if not all(raw_relations_rules): |
237 | + return |
238 | + for relation_rule in raw_relations_rules: |
239 | + try: |
240 | + app_0, endpoint_0 = self.check_app_endpoint_existence(relation_rule[0]) |
241 | + app_1, endpoint_1 = self.check_app_endpoint_existence(relation_rule[1]) |
242 | + if not all([app_0, endpoint_0, app_1, endpoint_1]): |
243 | + # means that app or endpoint was not found |
244 | + return |
245 | + if app_0 == self.charm: |
246 | + self.endpoint = endpoint_0 |
247 | + app_to_check = app_1 |
248 | + endpoint_to_check = endpoint_1 |
249 | + elif app_1 == self.charm: |
250 | + self.endpoint = endpoint_1 |
251 | + app_to_check = app_0 |
252 | + endpoint_to_check = endpoint_0 |
253 | + else: |
254 | + msg = ( |
255 | + "Relations rules has an unexpected format. " |
256 | + f"It was not possible to find {self.charm} on rules" |
257 | + ) |
258 | + LOGGER.warning(msg) |
259 | + return |
260 | + |
261 | + # check if all apps variations has the endpoint |
262 | + for app in self.charm_to_app: |
263 | + self.check_app_endpoint_existence(f"{app}:{self.endpoint}") |
264 | + self._relations.append([app_to_check, endpoint_to_check]) |
265 | + except (IndexError, ValueError) as e: |
266 | + raise RelationError(f"Relations rules has an unexpected format: {e}") |
267 | + |
268 | + def __repr__(self) -> str: |
269 | + """Representation of the RelationRule object. |
270 | + |
271 | + :return: representation. |
272 | + :rtype: str |
273 | + """ |
274 | + return f"{self.__class__.__name__}({self.charm} -> {self.endpoint})" |
275 | + |
276 | + def check(self) -> None: |
277 | + """Apply the relations rules check.""" |
278 | + self.relation_exist_check() |
279 | + self.relation_not_exist_check() |
280 | + |
281 | + def filter_by_app_and_endpoint(self, app: str, endpoint: str) -> Set: |
282 | + """Filter applications by the presence of an endpoint. |
283 | + |
284 | + :param app: Application to be filtered. |
285 | + :type app: str |
286 | + :param endpoint: Endpoint of a application. |
287 | + :type endpoint: str |
288 | + :return: Applications that matched with the endpoint passed. |
289 | + :rtype: Set |
290 | + """ |
291 | + # NOTE(gabrielcocenza) this function just works with fields from juju status. |
292 | + # when app == "*", filters all apps that have the endpoint passed. |
293 | + if app == "*": |
294 | + return { |
295 | + app |
296 | + for app in self.apps_to_check |
297 | + if endpoint |
298 | + in self.applications.get(app, {}).get("endpoint-bindings", {}) |
299 | + } |
300 | + return ( |
301 | + set([app]) |
302 | + if endpoint in self.applications.get(app, {}).get("endpoint-bindings", {}) |
303 | + else set() |
304 | + ) |
305 | + |
306 | + def filter_by_relation(self, apps: Set, endpoint: str) -> Set: |
307 | + """Filter applications that relate with an endpoint. |
308 | + |
309 | + :param apps: Applications to filter relations using the endpoint. |
310 | + :type apps: Set |
311 | + :param endpoint: endpoint of the applications. |
312 | + :type endpoint: str |
313 | + :return: Applications that has a relation with the apps using the endpoint. |
314 | + :rtype: Set |
315 | + """ |
316 | + # NOTE(gabrielcocenza) this function just works with fields from juju status. |
317 | + apps_related = set() |
318 | + for app in apps: |
319 | + relations = self.applications.get(app, {}).get("relations", {}) |
320 | + apps_related.update(relations.get(endpoint, [])) |
321 | + return apps_related |
322 | + |
323 | + def relation_exist_check(self) -> None: |
324 | + """Check if app(s) are relating with an endpoint.""" |
325 | + for relation in self.relations: |
326 | + app_to_check, endpoint_to_check = relation |
327 | + # applications in the bundle that have the endpoint to relate |
328 | + apps_with_endpoint_to_check = self.filter_by_app_and_endpoint( |
329 | + app_to_check, |
330 | + endpoint_to_check, |
331 | + ) |
332 | + # applications that are relating using the endpoint from the relation rule |
333 | + apps_related_with_relation_rule = self.filter_by_relation( |
334 | + self.charm_to_app, |
335 | + self.endpoint, |
336 | + ) |
337 | + self.missing_relations[f"{self.charm}:{self.endpoint}"] = sorted( |
338 | + list( |
339 | + apps_with_endpoint_to_check |
340 | + - apps_related_with_relation_rule |
341 | + - self.exception |
342 | + ) |
343 | + ) |
344 | + |
345 | + def relation_not_exist_check(self) -> None: |
346 | + """Check if a relation happens when it shouldn't. |
347 | + |
348 | + :raises RelationError: raise RelationError if it has wrong format. |
349 | + """ |
350 | + for relation in self.not_exist: |
351 | + if relation: |
352 | + app_endpoint_splitted = [] |
353 | + try: |
354 | + for app_endpoint in relation: |
355 | + app, endpoint = self.check_app_endpoint_existence(app_endpoint) |
356 | + app_endpoint_splitted.extend([app, endpoint]) |
357 | + ( |
358 | + app_to_check_0, |
359 | + endpoint_to_check_0, |
360 | + app_to_check_1, |
361 | + _, |
362 | + ) = app_endpoint_splitted |
363 | + relations_app_endpoint_0 = self.filter_by_relation( |
364 | + {app_to_check_0}, endpoint_to_check_0 |
365 | + ) |
366 | + if app_to_check_1 in relations_app_endpoint_0: |
367 | + self.not_exist_error.append(relation) |
368 | + |
369 | + except (IndexError, ValueError) as e: |
370 | + raise RelationError(f"Problem during check_relation_not_exist: {e}") |
371 | + |
372 | + def check_app_endpoint_existence(self, app_endpoint: str) -> Tuple[str, str]: |
373 | + """Check if app and endpoint exist on the object to lint. |
374 | + |
375 | + :param app_endpoint: app and endpoint separated by ":" with the following format: |
376 | + <application>:<application_endpoint> |
377 | + :type app_endpoint: str |
378 | + :return: application and endpoint |
379 | + :rtype: Tuple[str, str] |
380 | + """ |
381 | + app, endpoint = app_endpoint.split(":") |
382 | + # app == "*" means all apps |
383 | + # a charm from relation rule can have different app names. |
384 | + if app != "*" and app != self.charm: |
385 | + if app not in self.applications.keys(): |
386 | + LOGGER.warning(f"{app} not found on applications to check relations") |
387 | + return "", "" |
388 | + |
389 | + # juju-info is represented by "" on endpoint-bindings |
390 | + if endpoint != "juju-info" and endpoint not in self.applications[app].get( |
391 | + "endpoint-bindings", {} |
392 | + ): |
393 | + LOGGER.warning( |
394 | + f"{app} don't have the endpoint: {endpoint} to check relations" |
395 | + ) |
396 | + return "", "" |
397 | + return app, endpoint |
398 | + |
399 | + |
400 | +class RelationsRulesBootStrap: |
401 | + """Bootstrap all relations rules to be checked.""" |
402 | + |
403 | + def __init__( |
404 | + self, |
405 | + charm_to_app: Dict[str, Set], |
406 | + relations_rules: List[Dict[str, Any]], |
407 | + applications: Dict[str, Any], |
408 | + ): |
409 | + """Relations rules bootStrap object. |
410 | + |
411 | + :param charm_to_app: A charm can have more than one application name |
412 | + e.g:(nrpe-host and nrpe-container). This parameters contains all |
413 | + applications name of a given charm. |
414 | + :type charm_to_app: Dict[str, Set] |
415 | + :param relations_rules: Relations rules from the rule file. |
416 | + :type relations_rules: List[Dict[str, Any]] |
417 | + :param applications: All applications of the file passed to lint. |
418 | + :type applications: Dict[str, Any] |
419 | + """ |
420 | + self.charm_to_app = charm_to_app |
421 | + self.relations_rules = relations_rules |
422 | + self.applications = applications |
423 | + |
424 | + def check(self) -> List[RelationRule]: |
425 | + """Check all RelationRule objects. |
426 | + |
427 | + :return: List containing all RelationRule objects. |
428 | + :rtype: List[RelationRule] |
429 | + """ |
430 | + relations_rules = [ |
431 | + RelationRule( |
432 | + charm_to_app=self.charm_to_app.get(rule.get("charm", ""), set()), |
433 | + applications=self.applications, |
434 | + charm=rule.get("charm"), |
435 | + relations=rule.get("check", [[]]), |
436 | + not_exist=rule.get("not-exist", [[]]), |
437 | + exception=set(rule.get("exception", set())), |
438 | + ) |
439 | + for rule in self.relations_rules |
440 | + ] |
441 | + |
442 | + for rule in relations_rules: |
443 | + rule.check() |
444 | + return relations_rules |
445 | diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py |
446 | index a70ef63..4d3d3ee 100644 |
447 | --- a/tests/unit/conftest.py |
448 | +++ b/tests/unit/conftest.py |
449 | @@ -107,6 +107,10 @@ def juju_status(): |
450 | "charm": "cs:ubuntu-18", |
451 | "charm-name": "ubuntu", |
452 | "relations": {"juju-info": ["ntp"]}, |
453 | + "endpoint-bindings": { |
454 | + "": "external-space", |
455 | + "certificates": "external-space", |
456 | + }, |
457 | "units": { |
458 | "ubuntu/0": { |
459 | "juju-status": {"current": "idle"}, |
460 | @@ -126,6 +130,10 @@ def juju_status(): |
461 | "charm": "cs:ntp-47", |
462 | "charm-name": "ntp", |
463 | "relations": {"juju-info": ["ubuntu"]}, |
464 | + "endpoint-bindings": { |
465 | + "": "external-space", |
466 | + "certificates": "external-space", |
467 | + }, |
468 | }, |
469 | }, |
470 | "machines": { |
471 | @@ -229,3 +237,84 @@ def rules_files(): |
472 | return [ |
473 | str(rule.resolve()) for rule in Path("./contrib").iterdir() if rule.is_file() |
474 | ] |
475 | + |
476 | + |
477 | +def juju_status_relation(): |
478 | + """Representation of juju status input to test relations checks.""" |
479 | + return { |
480 | + "nrpe-container": { |
481 | + "charm": "cs:nrpe-61", |
482 | + "charm-name": "nrpe", |
483 | + "relations": { |
484 | + "monitors": ["nagios"], |
485 | + "nrpe-external-master": [ |
486 | + "keystone", |
487 | + ], |
488 | + }, |
489 | + "endpoint-bindings": { |
490 | + "general-info": "", |
491 | + "local-monitors": "", |
492 | + "monitors": "oam-space", |
493 | + "nrpe": "", |
494 | + "nrpe-external-master": "", |
495 | + }, |
496 | + }, |
497 | + "nrpe-host": { |
498 | + "charm": "cs:nrpe-67", |
499 | + "charm-name": "nrpe", |
500 | + "relations": { |
501 | + "monitors": ["nagios"], |
502 | + "nrpe-external-master": [ |
503 | + "elasticsearch", |
504 | + ], |
505 | + }, |
506 | + "endpoint-bindings": { |
507 | + "general-info": "", |
508 | + "local-monitors": "", |
509 | + "monitors": "oam-space", |
510 | + "nrpe": "", |
511 | + "nrpe-external-master": "", |
512 | + }, |
513 | + }, |
514 | + "keystone": { |
515 | + "charm": "cs:keystone-309", |
516 | + "charm-name": "keystone", |
517 | + "relations": { |
518 | + "nrpe-external-master": ["nrpe-container"], |
519 | + }, |
520 | + "endpoint-bindings": { |
521 | + "": "oam-space", |
522 | + "admin": "external-space", |
523 | + "certificates": "oam-space", |
524 | + "cluster": "oam-space", |
525 | + "domain-backend": "oam-space", |
526 | + "ha": "oam-space", |
527 | + "identity-admin": "oam-space", |
528 | + "identity-credentials": "oam-space", |
529 | + "identity-notifications": "oam-space", |
530 | + "identity-service": "oam-space", |
531 | + "internal": "internal-space", |
532 | + "keystone-fid-service-provider": "oam-space", |
533 | + "keystone-middleware": "oam-space", |
534 | + "nrpe-external-master": "oam-space", |
535 | + "public": "external-space", |
536 | + "shared-db": "internal-space", |
537 | + "websso-trusted-dashboard": "oam-space", |
538 | + }, |
539 | + }, |
540 | + "elasticsearch": { |
541 | + "charm": "cs:elasticsearch-39", |
542 | + "charm-name": "elasticsearch", |
543 | + "relations": { |
544 | + "nrpe-external-master": ["nrpe-host"], |
545 | + }, |
546 | + "endpoint-bindings": { |
547 | + "": "oam-space", |
548 | + "client": "oam-space", |
549 | + "data": "oam-space", |
550 | + "logs": "oam-space", |
551 | + "nrpe-external-master": "oam-space", |
552 | + "peer": "oam-space", |
553 | + }, |
554 | + }, |
555 | + } |
556 | diff --git a/tests/unit/test_jujulint.py b/tests/unit/test_jujulint.py |
557 | index 4394835..136044b 100644 |
558 | --- a/tests/unit/test_jujulint.py |
559 | +++ b/tests/unit/test_jujulint.py |
560 | @@ -7,7 +7,7 @@ from unittest import mock |
561 | import pytest |
562 | import yaml |
563 | |
564 | -from jujulint import check_spaces, lint |
565 | +from jujulint import check_spaces, lint, relations |
566 | |
567 | |
568 | class TestUtils: |
569 | @@ -1232,3 +1232,72 @@ applications: |
570 | def test_linter_atoi(self, input_str, expected_int, linter): |
571 | """Test conversion of string values (e.g. 2M (Megabytes)) to integers.""" |
572 | assert linter.atoi(input_str) == expected_int |
573 | + |
574 | + def test_check_relations_no_rules(self, linter, juju_status, mocker): |
575 | + """Warn message if rule file doesn't pass relations to check.""" |
576 | + mock_log = mocker.patch("jujulint.lint.Linter._log_with_header") |
577 | + linter.check_relations(juju_status["applications"]) |
578 | + mock_log.assert_called_with("No relation rules found. Skipping relation checks") |
579 | + |
580 | + def test_check_relations(self, linter, juju_status, mocker): |
581 | + """Ensure that check_relation pass.""" |
582 | + mock_handle_error = mocker.patch("jujulint.lint.Linter.handle_error") |
583 | + linter.lint_rules["relations"] = [ |
584 | + {"charm": "ntp", "check": [["ntp:juju-info", "ubuntu:juju-info"]]} |
585 | + ] |
586 | + linter.check_relations(juju_status["applications"]) |
587 | + mock_handle_error.assert_not_called() |
588 | + |
589 | + def test_check_relations_exception_handling(self, linter, juju_status, mocker): |
590 | + """Ensure that handle error if relation rules are in wrong format.""" |
591 | + mock_log = mocker.patch("jujulint.lint.Linter._log_with_header") |
592 | + mock_handle_error = mocker.patch("jujulint.lint.Linter.handle_error") |
593 | + linter.lint_rules["relations"] = [ |
594 | + {"charm": "ntp", "check": [["ntp", "ubuntu"]]} |
595 | + ] |
596 | + expected_msg = ( |
597 | + "Relations rules has an unexpected format: not enough " |
598 | + "values to unpack (expected 2, got 1)" |
599 | + ) |
600 | + expected_exception = relations.RelationError(expected_msg) |
601 | + linter.check_relations(juju_status["applications"]) |
602 | + mock_handle_error.assert_not_called() |
603 | + mock_log.assert_has_calls( |
604 | + [mocker.call(expected_exception.message, level=logging.ERROR)] |
605 | + ) |
606 | + |
607 | + def test_check_relations_missing_relations(self, linter, juju_status, mocker): |
608 | + """Ensure that check_relation handle missing relations.""" |
609 | + mock_handle_error = mocker.patch("jujulint.lint.Linter.handle_error") |
610 | + # add a relation rule that doesn't happen in the model |
611 | + linter.lint_rules["relations"] = [ |
612 | + {"charm": "ntp", "check": [["ntp:certificates", "ubuntu:certificates"]]} |
613 | + ] |
614 | + linter.check_relations(juju_status["applications"]) |
615 | + mock_handle_error.assert_called_with( |
616 | + { |
617 | + "id": "missing-relations", |
618 | + "tags": ["relation", "missing"], |
619 | + "message": "Endpoint '{}' is missing relations with: {}".format( |
620 | + "ntp:certificates", ["ubuntu"] |
621 | + ), |
622 | + } |
623 | + ) |
624 | + |
625 | + def test_check_relations_exist(self, linter, juju_status, mocker): |
626 | + """Ensure that check_relation handle not exist error.""" |
627 | + mock_handle_error = mocker.patch("jujulint.lint.Linter.handle_error") |
628 | + # add a relation rule that happen in the model |
629 | + linter.lint_rules["relations"] = [ |
630 | + {"charm": "ntp", "not-exist": [["ntp:juju-info", "ubuntu:juju-info"]]} |
631 | + ] |
632 | + linter.check_relations(juju_status["applications"]) |
633 | + mock_handle_error.assert_called_with( |
634 | + { |
635 | + "id": "relation-exist", |
636 | + "tags": ["relation", "exist"], |
637 | + "message": "Relation(s) {} should not exist.".format( |
638 | + ["ntp:juju-info", "ubuntu:juju-info"] |
639 | + ), |
640 | + } |
641 | + ) |
642 | diff --git a/tests/unit/test_relations.py b/tests/unit/test_relations.py |
643 | new file mode 100644 |
644 | index 0000000..9ec5d72 |
645 | --- /dev/null |
646 | +++ b/tests/unit/test_relations.py |
647 | @@ -0,0 +1,208 @@ |
648 | +#!/usr/bin/python3 |
649 | +"""Test the relations module.""" |
650 | +import pytest |
651 | + |
652 | +from jujulint import relations |
653 | + |
654 | +CHARM_TO_APP = {"nrpe-host", "nrpe-container"} |
655 | +CHARM = "nrpe" |
656 | +# rule to check all charms with nrpe-external-master endpoint. |
657 | +RELATIONS = [["*:nrpe-external-master", "nrpe:nrpe-external-master"]] |
658 | + |
659 | + |
660 | +@pytest.mark.parametrize( |
661 | + "correct_relation", |
662 | + [ |
663 | + RELATIONS, |
664 | + [ |
665 | + ["nrpe:nrpe-external-master", "*:nrpe-external-master"] |
666 | + ], # inverting sequence doesn't change the endpoint |
667 | + [ |
668 | + ["nrpe:nrpe-external-master", "keystone:nrpe-external-master"] |
669 | + ], # able to find specific app relation |
670 | + ], |
671 | +) |
672 | +def test_relation_rule_valid(correct_relation, juju_status_relation): |
673 | + """Missing rules have empty set for endpoints with expected relations.""" |
674 | + relation_rule = relations.RelationRule( |
675 | + charm_to_app=CHARM_TO_APP, |
676 | + applications=juju_status_relation, |
677 | + charm=CHARM, |
678 | + relations=correct_relation, |
679 | + not_exist=[[]], |
680 | + exception=set(), |
681 | + ) |
682 | + relation_rule.check() |
683 | + assert relation_rule.missing_relations == {"nrpe:nrpe-external-master": list()} |
684 | + assert relation_rule.not_exist_error == list() |
685 | + assert relation_rule.__repr__() == "RelationRule(nrpe -> nrpe-external-master)" |
686 | + assert relation_rule.endpoint == "nrpe-external-master" |
687 | + |
688 | + |
689 | +def test_relation_not_exist(juju_status_relation): |
690 | + """Ensure that finds a relation that shouldn't happen.""" |
691 | + juju_status_relation["foo-charm"] = { |
692 | + "charm": "cs:foo-charm-7", |
693 | + "charm-name": "foo-charm", |
694 | + "relations": { |
695 | + "foo-endpoint": ["keystone"], |
696 | + }, |
697 | + "endpoint-bindings": { |
698 | + "": "oam-space", |
699 | + "foo-endpoint": "oam-space", |
700 | + }, |
701 | + } |
702 | + juju_status_relation["keystone"]["relations"]["foo-endpoint"] = ["foo-charm"] |
703 | + juju_status_relation["keystone"]["endpoint-bindings"]["foo-endpoint"] = "oam-space" |
704 | + relation_rule = relations.RelationRule( |
705 | + charm_to_app=CHARM_TO_APP, |
706 | + applications=juju_status_relation, |
707 | + charm=CHARM, |
708 | + relations=RELATIONS, |
709 | + not_exist=[["keystone:foo-endpoint", "foo-charm:foo-endpoint"]], |
710 | + exception=set(), |
711 | + ) |
712 | + relation_rule.check() |
713 | + assert relation_rule.not_exist_error == [ |
714 | + ["keystone:foo-endpoint", "foo-charm:foo-endpoint"] |
715 | + ] |
716 | + |
717 | + |
718 | +def test_relation_not_exist_raise(juju_status_relation): |
719 | + """Test that raise exception when not_exist has wrong format.""" |
720 | + juju_status_relation["foo-charm"] = { |
721 | + "charm": "cs:foo-charm-7", |
722 | + "charm-name": "foo-charm", |
723 | + "relations": { |
724 | + "bar-endpoint": ["keystone"], |
725 | + }, |
726 | + "endpoint-bindings": { |
727 | + "": "oam-space", |
728 | + "bar-endpoint": "oam-space", |
729 | + }, |
730 | + } |
731 | + |
732 | + with pytest.raises(relations.RelationError): |
733 | + relation_rule = relations.RelationRule( |
734 | + charm_to_app=CHARM_TO_APP, |
735 | + applications=juju_status_relation, |
736 | + charm=CHARM, |
737 | + relations=RELATIONS, |
738 | + not_exist=[["keystone", "foo-charm:foo-endpoint"]], |
739 | + exception=set(), |
740 | + ) |
741 | + relation_rule.check() |
742 | + |
743 | + |
744 | +@pytest.mark.parametrize( |
745 | + "expected_missing, exception", |
746 | + [ |
747 | + ({"nrpe:nrpe-external-master": ["foo-charm"]}, set()), |
748 | + ({"nrpe:nrpe-external-master": list()}, {"foo-charm"}), |
749 | + ], |
750 | +) |
751 | +def test_missing_relation_and_exception( |
752 | + expected_missing, exception, juju_status_relation |
753 | +): |
754 | + """Assert that exception is able to remove apps missing the relation.""" |
755 | + # add a charm in apps that has the endpoint nrpe-external-master, |
756 | + # but it's not relating with nrpe. |
757 | + juju_status_relation["foo-charm"] = { |
758 | + "charm": "cs:foo-charm-7", |
759 | + "charm-name": "foo-charm", |
760 | + "relations": { |
761 | + "foo-endpoint": ["bar-charm"], |
762 | + }, |
763 | + "endpoint-bindings": { |
764 | + "": "oam-space", |
765 | + "nrpe-external-master": "oam-space", |
766 | + }, |
767 | + } |
768 | + relation_rule = relations.RelationRule( |
769 | + charm_to_app=CHARM_TO_APP, |
770 | + applications=juju_status_relation, |
771 | + charm=CHARM, |
772 | + relations=RELATIONS, |
773 | + not_exist=[[]], |
774 | + exception=exception, |
775 | + ) |
776 | + relation_rule.check() |
777 | + assert relation_rule.missing_relations == expected_missing |
778 | + |
779 | + |
780 | +def test_relation_rule_unknown_charm(mocker, juju_status_relation): |
781 | + """Empty relation for a unknown charm in rules and gives warning message.""" |
782 | + charm = "foo_charm" |
783 | + warning_msg = ( |
784 | + "Relations rules has an unexpected format. " |
785 | + f"It was not possible to find {charm} on rules" |
786 | + ) |
787 | + logger_mock = mocker.patch.object(relations, "LOGGER") |
788 | + relation_rule = relations.RelationRule( |
789 | + charm_to_app=CHARM_TO_APP, |
790 | + applications=juju_status_relation, |
791 | + charm="foo_charm", |
792 | + relations=[["*:public", "keystone:public"]], |
793 | + not_exist=[[]], |
794 | + exception=set(), |
795 | + ) |
796 | + assert relation_rule.relations == [] |
797 | + logger_mock.warning.assert_has_calls([mocker.call(warning_msg)]) |
798 | + |
799 | + |
800 | +@pytest.mark.parametrize( |
801 | + "fake_relations, app_error, endpoint_error", |
802 | + [ |
803 | + ([["foo:juju-info", "bar:juju-info"]], True, False), # app doesn't exist |
804 | + ([["keystone:bar", "nrpe-host:foo"]], False, True), # endpoint doesn't exist |
805 | + ], |
806 | +) |
807 | +def test_relation_rule_unknown_app_endpoint( |
808 | + fake_relations, app_error, endpoint_error, mocker, juju_status_relation |
809 | +): |
810 | + """Ensure warning message and empty relations if app or endpoint is unknown.""" |
811 | + logger_mock = mocker.patch.object(relations, "LOGGER") |
812 | + app, endpoint = fake_relations[0][0].split(":") |
813 | + if app_error: |
814 | + expected_msg = f"{app} not found on applications to check relations" |
815 | + elif endpoint_error: |
816 | + expected_msg = f"{app} don't have the endpoint: {endpoint} to check relations" |
817 | + |
818 | + relations_rule = relations.RelationRule( |
819 | + charm_to_app=CHARM_TO_APP, |
820 | + applications=juju_status_relation, |
821 | + charm=CHARM, |
822 | + relations=fake_relations, |
823 | + not_exist=[[]], |
824 | + exception=set(), |
825 | + ) |
826 | + # assert that relations is empty |
827 | + assert relations_rule.relations == [] |
828 | + logger_mock.warning.assert_has_calls([mocker.call(expected_msg)]) |
829 | + |
830 | + |
831 | +def test_relations_rules_bootstrap(juju_status_relation): |
832 | + """Test RelationsRulesBootStrap object.""" |
833 | + charm_to_app = {"nrpe": {"nrpe-host", "nrpe-container"}} |
834 | + relations_rules = [ |
835 | + { |
836 | + "charm": "nrpe", |
837 | + "check": [["*:nrpe-external-master", "nrpe:nrpe-external-master"]], |
838 | + }, |
839 | + { |
840 | + "charm": "elasticsearch", |
841 | + "check": [ |
842 | + ["elasticsearch:nrpe-external-master", "nrpe-host:nrpe-external-master"] |
843 | + ], |
844 | + }, |
845 | + ] |
846 | + relations_rules = relations.RelationsRulesBootStrap( |
847 | + charm_to_app=charm_to_app, |
848 | + relations_rules=relations_rules, |
849 | + applications=juju_status_relation, |
850 | + ).check() |
851 | + assert len(relations_rules) == 2 |
852 | + assert all( |
853 | + isinstance(relation_rule, relations.RelationRule) |
854 | + for relation_rule in relations_rules |
855 | + ) |
856 | diff --git a/tox.ini b/tox.ini |
857 | index 31ae64d..a89fd0b 100644 |
858 | --- a/tox.ini |
859 | +++ b/tox.ini |
860 | @@ -8,7 +8,7 @@ exclude = |
861 | venv, |
862 | max-line-length = 120 |
863 | max-complexity = 10 |
864 | -ignore = D100, D103, C901 |
865 | +ignore = D100, D103, C901, W503 |
866 | |
867 | [tox] |
868 | skipsdist=True |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.