Merge ~gabrielcocenza/juju-lint:bug/1965762-B into juju-lint: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)
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

[0] https://pastebin.canonical.com/p/k4FgszYdhf/

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
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

[0] https://pastebin.canonical.com/p/k4FgszYdhf/

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]

[0] https://pastebin.canonical.com/p/MzKvj7Y8hQ/

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 e41480d686387ef10326f9d54c26301297761f12

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/contrib/includes/aggregator-kubernetes.yaml b/contrib/includes/aggregator-kubernetes.yaml
2index 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
13diff --git a/contrib/includes/aggregator-openstack.yaml b/contrib/includes/aggregator-openstack.yaml
14index 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
24diff --git a/contrib/includes/base.yaml b/contrib/includes/base.yaml
25index 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+ ]
39diff --git a/contrib/includes/kubernetes.yaml b/contrib/includes/kubernetes.yaml
40index 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+ ]
57diff --git a/jujulint/lint.py b/jujulint/lint.py
58index 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:
143diff --git a/jujulint/relations.py b/jujulint/relations.py
144new file mode 100644
145index 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
445diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
446index 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+ }
556diff --git a/tests/unit/test_jujulint.py b/tests/unit/test_jujulint.py
557index 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+ )
642diff --git a/tests/unit/test_relations.py b/tests/unit/test_relations.py
643new file mode 100644
644index 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+ )
856diff --git a/tox.ini b/tox.ini
857index 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

Subscribers

People subscribed via source and target branches