Merge ~jfguedez/juju-lint:bug/1903973 into juju-lint:master

Proposed by Jose Guedez
Status: Merged
Merged at revision: 3b85de9d81bf29c7769febd0a60c7a41fc424a34
Proposed branch: ~jfguedez/juju-lint:bug/1903973
Merge into: juju-lint:master
Diff against target: 340 lines (+158/-47)
5 files modified
contrib/includes/base.yaml (+1/-1)
jujulint/lint.py (+22/-8)
jujulint/util.py (+25/-3)
tests/conftest.py (+6/-0)
tests/test_jujulint.py (+104/-35)
Reviewer Review Type Date Requested Status
Edin S (community) Approve
Juju Lint maintainers Pending
Review via email: mp+408853@code.launchpad.net

Commit message

Add the "metal only" condition for subordinates

To post a comment you must log in.
Revision history for this message
Jose Guedez (jfguedez) wrote :

Add the "metal only" condition for subordinates

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
Edin S (exsdev) :
review: Approve
Revision history for this message
James Troup (elmo) wrote :

I had to thought check MAC addresses, FWIW; it's also a heuristic, but a fairly reliable one for detecting the most common virt platforms (KVM/VMWare).

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/contrib/includes/base.yaml b/contrib/includes/base.yaml
index 1815ee2..da558a3 100644
--- a/contrib/includes/base.yaml
+++ b/contrib/includes/base.yaml
@@ -35,6 +35,6 @@ subordinates:
35 thruk-agent:35 thruk-agent:
36 where: on nagios36 where: on nagios
37 hw-health:37 hw-health:
38 where: host only38 where: metal only
39 logrotated:39 logrotated:
40 where: all40 where: all
diff --git a/jujulint/lint.py b/jujulint/lint.py
index 68fef78..f680370 100755
--- a/jujulint/lint.py
+++ b/jujulint/lint.py
@@ -33,7 +33,7 @@ import attr
33import dateutil.parser33import dateutil.parser
34from dateutil import relativedelta34from dateutil import relativedelta
3535
36from jujulint.util import flatten_list, is_container, extract_charm_name36import jujulint.util as utils
37from jujulint.logging import Logger37from jujulint.logging import Logger
3838
39VALID_CONFIG_CHECKS = ("isset", "eq", "neq", "gte")39VALID_CONFIG_CHECKS = ("isset", "eq", "neq", "gte")
@@ -133,7 +133,9 @@ class Linter:
133 self.lint_rules["subordinates"][name] = dict(where=where)133 self.lint_rules["subordinates"][name] = dict(where=where)
134134
135 # Flatten all entries (to account for nesting due to YAML anchors (templating)135 # Flatten all entries (to account for nesting due to YAML anchors (templating)
136 self.lint_rules = {k: flatten_list(v) for k, v in self.lint_rules.items()}136 self.lint_rules = {
137 k: utils.flatten_list(v) for k, v in self.lint_rules.items()
138 }
137139
138 self._log_with_header(140 self._log_with_header(
139 "Lint Rules: {}".format(pprint.pformat(self.lint_rules))141 "Lint Rules: {}".format(pprint.pformat(self.lint_rules))
@@ -372,7 +374,7 @@ class Linter:
372 )374 )
373 continue375 continue
374376
375 charm_name = extract_charm_name(applications[application]["charm"])377 charm_name = utils.extract_charm_name(applications[application]["charm"])
376 if "config" in self.lint_rules:378 if "config" in self.lint_rules:
377 if charm_name in self.lint_rules["config"]:379 if charm_name in self.lint_rules["config"]:
378 lint_rules = self.lint_rules["config"][charm_name].items()380 lint_rules = self.lint_rules["config"][charm_name].items()
@@ -393,7 +395,7 @@ class Linter:
393 lint_rules,395 lint_rules,
394 )396 )
395397
396 def check_subs(self):398 def check_subs(self, machines_data):
397 """Check the subordinates in the model."""399 """Check the subordinates in the model."""
398 all_or_nothing = set()400 all_or_nothing = set()
399 for machine in self.model.subs_on_machines:401 for machine in self.model.subs_on_machines:
@@ -429,7 +431,7 @@ class Linter:
429 self._log_with_header(431 self._log_with_header(
430 "requirement is 'host only' form...."432 "requirement is 'host only' form...."
431 )433 )
432 if is_container(machine):434 if utils.is_container(machine):
433 self._log_with_header("... and we are a container, checking")435 self._log_with_header("... and we are a container, checking")
434 # XXX check alternate names?436 # XXX check alternate names?
435 if required_sub in present_subs:437 if required_sub in present_subs:
@@ -438,6 +440,18 @@ class Linter:
438 self.model.extraneous_subs[required_sub].add(app)440 self.model.extraneous_subs[required_sub].add(app)
439 continue441 continue
440 self._log_with_header("... and we are a host, will fallthrough")442 self._log_with_header("... and we are a host, will fallthrough")
443 elif where == "metal only":
444 self._log_with_header(
445 "requirement is 'metal only' form...."
446 )
447 if not utils.is_metal(machine, machines_data.get(machine, {})):
448 self._log_with_header("... and we are not a metal, checking")
449 if required_sub in present_subs:
450 self._log_with_header("... found extraneous sub")
451 for app in self.model.apps_on_machines[machine]:
452 self.model.extraneous_subs[required_sub].add(app)
453 continue
454 self._log_with_header("... and we are a metal, will fallthrough")
441455
442 elif where == "all or nothing" and required_sub not in all_or_nothing:456 elif where == "all or nothing" and required_sub not in all_or_nothing:
443 self._log_with_header(457 self._log_with_header(
@@ -448,7 +462,7 @@ class Linter:
448 # need to change the name we expect to see it as462 # need to change the name we expect to see it as
449 elif where == "container aware":463 elif where == "container aware":
450 self._log_with_header("requirement is 'container aware'.")464 self._log_with_header("requirement is 'container aware'.")
451 if is_container(machine):465 if utils.is_container(machine):
452 suffixes = self.lint_rules["subordinates"][required_sub][466 suffixes = self.lint_rules["subordinates"][required_sub][
453 "container-suffixes"467 "container-suffixes"
454 ]468 ]
@@ -719,7 +733,7 @@ class Linter:
719 """Process applications in the model, validating and normalising the names."""733 """Process applications in the model, validating and normalising the names."""
720 for app in applications:734 for app in applications:
721 if "charm" in applications[app]:735 if "charm" in applications[app]:
722 charm_name = extract_charm_name(applications[app]["charm"])736 charm_name = utils.extract_charm_name(applications[app]["charm"])
723 self.model.charms.add(charm_name)737 self.model.charms.add(charm_name)
724 self.model.app_to_charm[app] = charm_name738 self.model.app_to_charm[app] = charm_name
725 else:739 else:
@@ -973,7 +987,7 @@ class Linter:
973 for app in parsed_yaml[applications]:987 for app in parsed_yaml[applications]:
974 self.process_subordinates(parsed_yaml[applications][app], app)988 self.process_subordinates(parsed_yaml[applications][app], app)
975989
976 self.check_subs()990 self.check_subs(parsed_yaml["machines"])
977 self.check_charms()991 self.check_charms()
978992
979 if "relations" not in parsed_yaml:993 if "relations" not in parsed_yaml:
diff --git a/jujulint/util.py b/jujulint/util.py
index c6eb80e..fc45ee6 100644
--- a/jujulint/util.py
+++ b/jujulint/util.py
@@ -50,13 +50,35 @@ def is_container(machine):
50 return False50 return False
5151
5252
53def is_virtual_machine(machine, machine_data):
54 """
55 Check if a provided machine is a VM.
56
57 It is not straightforward to determine if a machine is a VM from juju data
58 (bundle/juju status). In some cases a "hardware" key is provided (jsfy),
59 and in those cases we can check for the keyword "virtual" since some
60 provisioners include a tag there (FCE). We use that criteria as a best
61 effort attempt to determine if the machine is a VM.
62 """
63 hardware = machine_data.get("hardware")
64 return bool(hardware and "virtual" in hardware)
65
66
67def is_metal(machine, machine_data):
68 """
69 Check if a provided machine is a bare metal host.
70
71 Leverages the other detection methods, if the others fail (e.g. not a
72 container or VM), we consider the machine to be bare metal.
73 """
74 return not (is_container(machine) or is_virtual_machine(machine, machine_data))
75
76
53def extract_charm_name(charm):77def extract_charm_name(charm):
54 """Extract the charm name using regex."""78 """Extract the charm name using regex."""
55 match = re.match(79 match = re.match(
56 r"^(?:\w+:)?(?:~[\w\.-]+/)?(?:\w+/)?([a-zA-Z0-9-]+?)(?:-\d+)?$", charm80 r"^(?:\w+:)?(?:~[\w\.-]+/)?(?:\w+/)?([a-zA-Z0-9-]+?)(?:-\d+)?$", charm
57 )81 )
58 if not match:82 if not match:
59 raise InvalidCharmNameError(83 raise InvalidCharmNameError("charm name '{}' is invalid".format(charm))
60 "charm name '{}' is invalid".format(charm)
61 )
62 return match.group(1)84 return match.group(1)
diff --git a/tests/conftest.py b/tests/conftest.py
index ba9ed77..fc7093e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -98,6 +98,12 @@ def juju_status():
98 }98 }
99 },99 },
100 },100 },
101 "ntp": {
102 "application-status": {"current": "active"},
103 "charm": "cs:ntp-47",
104 "charm-name": "ntp",
105 "relations": {"juju-info": ["ubuntu"]},
106 },
101 },107 },
102 "machines": {108 "machines": {
103 "0": {109 "0": {
diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
index ce123ee..2494c28 100644
--- a/tests/test_jujulint.py
+++ b/tests/test_jujulint.py
@@ -5,41 +5,59 @@ from datetime import datetime, timezone
5import pytest5import pytest
66
77
8def test_flatten_list(utils):8class TestUtils:
9 """Test the utils flatten_list function."""9 """Test the jujulint utilities."""
10 unflattened_list = [1, [2, 3]]10
11 flattened_list = [1, 2, 3]11 def test_flatten_list(self, utils):
12 assert flattened_list == utils.flatten_list(unflattened_list)12 """Test the utils flatten_list function."""
1313 unflattened_list = [1, [2, 3]]
14 unflattened_list = [1, [2, [3, 4]]]14 flattened_list = [1, 2, 3]
15 flattened_list = [1, 2, 3, 4]15 assert flattened_list == utils.flatten_list(unflattened_list)
16 assert flattened_list == utils.flatten_list(unflattened_list)16
1717 unflattened_list = [1, [2, [3, 4]]]
1818 flattened_list = [1, 2, 3, 4]
19def test_flatten_list_non_list_iterable(utils):19 assert flattened_list == utils.flatten_list(unflattened_list)
20 """Test the utils flatten_list function."""20
21 iterable = {1: 2}21 def test_flatten_list_non_list_iterable(self, utils):
22 assert iterable == utils.flatten_list(iterable)22 """Test the utils flatten_list function."""
2323 iterable = {1: 2}
2424 assert iterable == utils.flatten_list(iterable)
25def test_map_charms(linter, utils):25
26 """Test the charm name validation code."""26 def test_is_container(self, utils):
27 applications = {27 """Test the utils is_container function."""
28 "test-app-1": {"charm": "cs:~USER/SERIES/TEST-CHARM12-123"},28 assert utils.is_container("1/lxd/0") is True
29 "test-app-2": {"charm": "cs:~USER/TEST-CHARM12-123"},29 assert utils.is_container("0") is False
30 "test-app-3": {"charm": "cs:TEST-CHARM12-123"},30
31 "test-app-4": {"charm": "local:SERIES/TEST-CHARM12"},31 def test_is_virtual_machine(self, utils):
32 "test-app-5": {"charm": "local:TEST-CHARM12"},32 """Test the utils is_virtual_machine function."""
33 "test-app-6": {"charm": "cs:~TEST-CHARMERS/TEST-CHARM12-123"},33 machine = "0"
34 }34 machine_data = {
35 linter.map_charms(applications)35 "hardware": "arch=amd64 cores=2 mem=4096M tags=virtual,pod-console-logging,vault availability-zone=AZ3"
36 for charm in linter.model.charms:36 }
37 assert "TEST-CHARM12" == charm37 assert utils.is_virtual_machine(machine, machine_data) is True
38 applications = {38
39 "test-app1": {"charm": "cs:invalid-charm$"},39 machine_data = {}
40 }40 assert utils.is_virtual_machine(machine, machine_data) is False
41 with pytest.raises(utils.InvalidCharmNameError):41
42 linter.map_charms(applications)42 def test_is_metal(self, utils):
43 """Test the utils is_metal function."""
44 # A VM should return false
45 machine = "0"
46 machine_data = {
47 "hardware": "arch=amd64 cores=2 mem=4096M tags=virtual,pod-console-logging,vault availability-zone=AZ3"
48 }
49 assert utils.is_metal(machine, machine_data) is False
50
51 # A container should return false
52 assert utils.is_metal("1/lxd/0", {}) is False
53
54 # A bare metal should return true
55 machine = "1"
56 machine_data = {
57 "hardware": "arch=amd64 cores=128 mem=2093056M tags=foundation-nodes,hyper-converged-az2 "
58 "availability-zone=AZ2"
59 }
60 assert utils.is_metal(machine, machine_data) is True
4361
4462
45class TestLinter:63class TestLinter:
@@ -77,6 +95,25 @@ class TestLinter:
77 assert errors[0]["id"] == "charm-not-mapped"95 assert errors[0]["id"] == "charm-not-mapped"
78 assert errors[0]["application"] == "ubuntu2"96 assert errors[0]["application"] == "ubuntu2"
7997
98 def test_map_charms(self, linter, utils):
99 """Test the charm name validation code."""
100 applications = {
101 "test-app-1": {"charm": "cs:~USER/SERIES/TEST-CHARM12-123"},
102 "test-app-2": {"charm": "cs:~USER/TEST-CHARM12-123"},
103 "test-app-3": {"charm": "cs:TEST-CHARM12-123"},
104 "test-app-4": {"charm": "local:SERIES/TEST-CHARM12"},
105 "test-app-5": {"charm": "local:TEST-CHARM12"},
106 "test-app-6": {"charm": "cs:~TEST-CHARMERS/TEST-CHARM12-123"},
107 }
108 linter.map_charms(applications)
109 for charm in linter.model.charms:
110 assert "TEST-CHARM12" == charm
111 applications = {
112 "test-app1": {"charm": "cs:invalid-charm$"},
113 }
114 with pytest.raises(utils.InvalidCharmNameError):
115 linter.map_charms(applications)
116
80 def test_juju_status_unexpected(self, linter, juju_status):117 def test_juju_status_unexpected(self, linter, juju_status):
81 """Test that juju and workload status is expected."""118 """Test that juju and workload status is expected."""
82 # inject invalid status to the application119 # inject invalid status to the application
@@ -243,6 +280,37 @@ class TestLinter:
243 assert errors[0]["machines"] == "0"280 assert errors[0]["machines"] == "0"
244 assert errors[0]["subordinate"] == "ntp"281 assert errors[0]["subordinate"] == "ntp"
245282
283 def test_ops_subordinate_metal_only1(self, linter, juju_status):
284 """
285 Test that missing ops subordinate charms are detected.
286
287 Use the "metal only" rule in a bare metal machine, should report the
288 missing subordinate
289 """
290 linter.lint_rules["subordinates"]["hw-health"] = {"where": "metal only"}
291 linter.do_lint(juju_status)
292
293 errors = linter.output_collector["errors"]
294 assert len(errors) == 1
295 assert errors[0]["id"] == "ops-subordinate-missing"
296 assert errors[0]["principals"] == "ubuntu"
297 assert errors[0]["subordinate"] == "hw-health"
298
299 def test_ops_subordinate_metal_only2(self, linter, juju_status):
300 """
301 Test that missing ops subordinate charms are detected.
302
303 Use the "metal only" rule in a VM, should ignore it
304 """
305 linter.lint_rules["subordinates"]["hw-health"] = {"where": "metal only"}
306
307 # Turn machine "0" into a "VM"
308 juju_status["machines"]["0"]["hardware"] = "tags=virtual availability-zone=rack-1"
309 linter.do_lint(juju_status)
310
311 errors = linter.output_collector["errors"]
312 assert not errors
313
246 def test_openstack_charm_missing(self, linter, juju_status):314 def test_openstack_charm_missing(self, linter, juju_status):
247 """Test that missing openstack mandatory charms are detected."""315 """Test that missing openstack mandatory charms are detected."""
248 linter.cloud_type = "openstack"316 linter.cloud_type = "openstack"
@@ -286,6 +354,7 @@ class TestLinter:
286 linter.cloud_type = "kubernetes"354 linter.cloud_type = "kubernetes"
287 linter.lint_rules["kubernetes mandatory"] = []355 linter.lint_rules["kubernetes mandatory"] = []
288 linter.lint_rules["operations kubernetes mandatory"] = ["ntp"]356 linter.lint_rules["operations kubernetes mandatory"] = ["ntp"]
357 juju_status["applications"].pop("ntp") # drop the app from the model
289 linter.do_lint(juju_status)358 linter.do_lint(juju_status)
290359
291 errors = linter.output_collector["errors"]360 errors = linter.output_collector["errors"]

Subscribers

People subscribed via source and target branches