diff options
5 files changed, 362 insertions, 10 deletions
diff --git a/roles/openshift_health_checker/openshift_checks/disk_availability.py b/roles/openshift_health_checker/openshift_checks/disk_availability.py new file mode 100644 index 000000000..c2792a0fe --- /dev/null +++ b/roles/openshift_health_checker/openshift_checks/disk_availability.py @@ -0,0 +1,65 @@ +# pylint: disable=missing-docstring +from openshift_checks import OpenShiftCheck, OpenShiftCheckException, get_var +from openshift_checks.mixins import NotContainerizedMixin + + +class DiskAvailability(NotContainerizedMixin, OpenShiftCheck): + """Check that recommended disk space is available before a first-time install.""" + + name = "disk_availability" + tags = ["preflight"] + + # Values taken from the official installation documentation: + # https://docs.openshift.org/latest/install_config/install/prerequisites.html#system-requirements + recommended_disk_space_bytes = { + "masters": 40 * 10**9, + "nodes": 15 * 10**9, + "etcd": 20 * 10**9, + } + + @classmethod + def is_active(cls, task_vars): + """Skip hosts that do not have recommended disk space requirements.""" + group_names = get_var(task_vars, "group_names", default=[]) + has_disk_space_recommendation = bool(set(group_names).intersection(cls.recommended_disk_space_bytes)) + return super(DiskAvailability, cls).is_active(task_vars) and has_disk_space_recommendation + + def run(self, tmp, task_vars): + group_names = get_var(task_vars, "group_names") + ansible_mounts = get_var(task_vars, "ansible_mounts") + + min_free_bytes = max(self.recommended_disk_space_bytes.get(name, 0) for name in group_names) + free_bytes = self.openshift_available_disk(ansible_mounts) + + if free_bytes < min_free_bytes: + return { + 'failed': True, + 'msg': ( + 'Available disk space ({:.1f} GB) for the volume containing ' + '"/var" is below minimum recommended space ({:.1f} GB)' + ).format(float(free_bytes) / 10**9, float(min_free_bytes) / 10**9) + } + + return {} + + @staticmethod + def openshift_available_disk(ansible_mounts): + """Determine the available disk space for an OpenShift installation. + + ansible_mounts should be a list of dicts like the 'setup' Ansible module + returns. + """ + # priority list in descending order + supported_mnt_paths = ["/var", "/"] + available_mnts = {mnt.get("mount"): mnt for mnt in ansible_mounts} + + try: + for path in supported_mnt_paths: + if path in available_mnts: + return available_mnts[path]["size_available"] + except KeyError: + pass + + paths = ''.join(sorted(available_mnts)) or 'none' + msg = "Unable to determine available disk space. Paths mounted: {}.".format(paths) + raise OpenShiftCheckException(msg) diff --git a/roles/openshift_health_checker/openshift_checks/memory_availability.py b/roles/openshift_health_checker/openshift_checks/memory_availability.py new file mode 100644 index 000000000..28805dc37 --- /dev/null +++ b/roles/openshift_health_checker/openshift_checks/memory_availability.py @@ -0,0 +1,44 @@ +# pylint: disable=missing-docstring +from openshift_checks import OpenShiftCheck, get_var + + +class MemoryAvailability(OpenShiftCheck): + """Check that recommended memory is available.""" + + name = "memory_availability" + tags = ["preflight"] + + # Values taken from the official installation documentation: + # https://docs.openshift.org/latest/install_config/install/prerequisites.html#system-requirements + recommended_memory_bytes = { + "masters": 16 * 10**9, + "nodes": 8 * 10**9, + "etcd": 20 * 10**9, + } + + @classmethod + def is_active(cls, task_vars): + """Skip hosts that do not have recommended memory requirements.""" + group_names = get_var(task_vars, "group_names", default=[]) + has_memory_recommendation = bool(set(group_names).intersection(cls.recommended_memory_bytes)) + return super(MemoryAvailability, cls).is_active(task_vars) and has_memory_recommendation + + def run(self, tmp, task_vars): + group_names = get_var(task_vars, "group_names") + total_memory_bytes = get_var(task_vars, "ansible_memtotal_mb") * 10**6 + + min_memory_bytes = max(self.recommended_memory_bytes.get(name, 0) for name in group_names) + + if total_memory_bytes < min_memory_bytes: + return { + 'failed': True, + 'msg': ( + 'Available memory ({available:.1f} GB) ' + 'below recommended value ({recommended:.1f} GB)' + ).format( + available=float(total_memory_bytes) / 10**9, + recommended=float(min_memory_bytes) / 10**9, + ), + } + + return {} diff --git a/roles/openshift_health_checker/openshift_checks/mixins.py b/roles/openshift_health_checker/openshift_checks/mixins.py index 657e15160..20d160eaf 100644 --- a/roles/openshift_health_checker/openshift_checks/mixins.py +++ b/roles/openshift_health_checker/openshift_checks/mixins.py @@ -1,4 +1,8 @@ -# pylint: disable=missing-docstring +# pylint: disable=missing-docstring,too-few-public-methods +""" +Mixin classes meant to be used with subclasses of OpenShiftCheck. +""" + from openshift_checks import get_var @@ -7,12 +11,5 @@ class NotContainerizedMixin(object): @classmethod def is_active(cls, task_vars): - return ( - # This mixin is meant to be used with subclasses of OpenShiftCheck. - super(NotContainerizedMixin, cls).is_active(task_vars) and - not cls.is_containerized(task_vars) - ) - - @staticmethod - def is_containerized(task_vars): - return get_var(task_vars, "openshift", "common", "is_containerized") + is_containerized = get_var(task_vars, "openshift", "common", "is_containerized") + return super(NotContainerizedMixin, cls).is_active(task_vars) and not is_containerized diff --git a/roles/openshift_health_checker/test/disk_availability_test.py b/roles/openshift_health_checker/test/disk_availability_test.py new file mode 100644 index 000000000..970b474d7 --- /dev/null +++ b/roles/openshift_health_checker/test/disk_availability_test.py @@ -0,0 +1,155 @@ +import pytest + +from openshift_checks.disk_availability import DiskAvailability, OpenShiftCheckException + + +@pytest.mark.parametrize('group_names,is_containerized,is_active', [ + (['masters'], False, True), + # ensure check is skipped on containerized installs + (['masters'], True, False), + (['nodes'], False, True), + (['etcd'], False, True), + (['masters', 'nodes'], False, True), + (['masters', 'etcd'], False, True), + ([], False, False), + (['lb'], False, False), + (['nfs'], False, False), +]) +def test_is_active(group_names, is_containerized, is_active): + task_vars = dict( + group_names=group_names, + openshift=dict(common=dict(is_containerized=is_containerized)), + ) + assert DiskAvailability.is_active(task_vars=task_vars) == is_active + + +@pytest.mark.parametrize('ansible_mounts,extra_words', [ + ([], ['none']), # empty ansible_mounts + ([{'mount': '/mnt'}], ['/mnt']), # missing relevant mount paths + ([{'mount': '/var'}], ['/var']), # missing size_available +]) +def test_cannot_determine_available_disk(ansible_mounts, extra_words): + task_vars = dict( + group_names=['masters'], + ansible_mounts=ansible_mounts, + ) + check = DiskAvailability(execute_module=fake_execute_module) + + with pytest.raises(OpenShiftCheckException) as excinfo: + check.run(tmp=None, task_vars=task_vars) + + for word in 'determine available disk'.split() + extra_words: + assert word in str(excinfo.value) + + +@pytest.mark.parametrize('group_names,ansible_mounts', [ + ( + ['masters'], + [{ + 'mount': '/', + 'size_available': 40 * 10**9 + 1, + }], + ), + ( + ['nodes'], + [{ + 'mount': '/', + 'size_available': 15 * 10**9 + 1, + }], + ), + ( + ['etcd'], + [{ + 'mount': '/', + 'size_available': 20 * 10**9 + 1, + }], + ), + ( + ['etcd'], + [{ + # not enough space on / ... + 'mount': '/', + 'size_available': 0, + }, { + # ... but enough on /var + 'mount': '/var', + 'size_available': 20 * 10**9 + 1, + }], + ), +]) +def test_succeeds_with_recommended_disk_space(group_names, ansible_mounts): + task_vars = dict( + group_names=group_names, + ansible_mounts=ansible_mounts, + ) + + check = DiskAvailability(execute_module=fake_execute_module) + result = check.run(tmp=None, task_vars=task_vars) + + assert not result.get('failed', False) + + +@pytest.mark.parametrize('group_names,ansible_mounts,extra_words', [ + ( + ['masters'], + [{ + 'mount': '/', + 'size_available': 1, + }], + ['0.0 GB'], + ), + ( + ['nodes'], + [{ + 'mount': '/', + 'size_available': 1 * 10**9, + }], + ['1.0 GB'], + ), + ( + ['etcd'], + [{ + 'mount': '/', + 'size_available': 1, + }], + ['0.0 GB'], + ), + ( + ['nodes', 'masters'], + [{ + 'mount': '/', + # enough space for a node, not enough for a master + 'size_available': 15 * 10**9 + 1, + }], + ['15.0 GB'], + ), + ( + ['etcd'], + [{ + # enough space on / ... + 'mount': '/', + 'size_available': 20 * 10**9 + 1, + }, { + # .. but not enough on /var + 'mount': '/var', + 'size_available': 0, + }], + ['0.0 GB'], + ), +]) +def test_fails_with_insufficient_disk_space(group_names, ansible_mounts, extra_words): + task_vars = dict( + group_names=group_names, + ansible_mounts=ansible_mounts, + ) + + check = DiskAvailability(execute_module=fake_execute_module) + result = check.run(tmp=None, task_vars=task_vars) + + assert result['failed'] + for word in 'below recommended'.split() + extra_words: + assert word in result['msg'] + + +def fake_execute_module(*args): + raise AssertionError('this function should not be called') diff --git a/roles/openshift_health_checker/test/memory_availability_test.py b/roles/openshift_health_checker/test/memory_availability_test.py new file mode 100644 index 000000000..e161a5b9e --- /dev/null +++ b/roles/openshift_health_checker/test/memory_availability_test.py @@ -0,0 +1,91 @@ +import pytest + +from openshift_checks.memory_availability import MemoryAvailability + + +@pytest.mark.parametrize('group_names,is_active', [ + (['masters'], True), + (['nodes'], True), + (['etcd'], True), + (['masters', 'nodes'], True), + (['masters', 'etcd'], True), + ([], False), + (['lb'], False), + (['nfs'], False), +]) +def test_is_active(group_names, is_active): + task_vars = dict( + group_names=group_names, + ) + assert MemoryAvailability.is_active(task_vars=task_vars) == is_active + + +@pytest.mark.parametrize('group_names,ansible_memtotal_mb', [ + ( + ['masters'], + 17200, + ), + ( + ['nodes'], + 8200, + ), + ( + ['etcd'], + 22200, + ), + ( + ['masters', 'nodes'], + 17000, + ), +]) +def test_succeeds_with_recommended_memory(group_names, ansible_memtotal_mb): + task_vars = dict( + group_names=group_names, + ansible_memtotal_mb=ansible_memtotal_mb, + ) + + check = MemoryAvailability(execute_module=fake_execute_module) + result = check.run(tmp=None, task_vars=task_vars) + + assert not result.get('failed', False) + + +@pytest.mark.parametrize('group_names,ansible_memtotal_mb,extra_words', [ + ( + ['masters'], + 0, + ['0.0 GB'], + ), + ( + ['nodes'], + 100, + ['0.1 GB'], + ), + ( + ['etcd'], + -1, + ['0.0 GB'], + ), + ( + ['nodes', 'masters'], + # enough memory for a node, not enough for a master + 11000, + ['11.0 GB'], + ), +]) +def test_fails_with_insufficient_memory(group_names, ansible_memtotal_mb, extra_words): + task_vars = dict( + group_names=group_names, + ansible_memtotal_mb=ansible_memtotal_mb, + ) + + check = MemoryAvailability(execute_module=fake_execute_module) + result = check.run(tmp=None, task_vars=task_vars) + + assert result['failed'] + for word in 'below recommended'.split() + extra_words: + assert word in result['msg'] + + +def fake_execute_module(*args): + raise AssertionError('this function should not be called') |