From 8eecd088e64bc512c0795b5680487b12c64ac2af Mon Sep 17 00:00:00 2001
From: Rob Crittenden <rcritten@redhat.com>
Date: Thu, 27 Aug 2020 15:22:12 -0400
Subject: [PATCH 1/2] Require at least 1.6Gb of available RAM to install the
 server

Verify that there is at least 1.6Gb of usable RAM on the system. Swap
is not considered. While swap would allow a user to minimally install
IPA it would not be a great experience.

Using any proc-based method to check for available RAM does not
work in containers unless /proc is re-mounted so use cgroups
instead. This also handles the case if the container has memory
constraints on it (-m).

There are envs which mount 'proc' with enabled hidepid option 1
so don't assume that is readable.

Add a switch to skip this memory test if the user is sure they
know what they are doing.

is_hidepid() contributed by Stanislav Levin <slev@altlinux.org>

https://pagure.io/freeipa/issue/8404
---
 freeipa.spec.in                            |  2 +
 ipaserver/install/installutils.py          | 80 ++++++++++++++++++++++
 ipaserver/install/server/__init__.py       |  6 ++
 ipaserver/install/server/install.py        |  2 +
 ipaserver/install/server/replicainstall.py |  6 +-
 5 files changed, 94 insertions(+), 2 deletions(-)

diff --git a/freeipa.spec.in b/freeipa.spec.in
index 70f5bf7ae9..96b5113661 100755
--- a/freeipa.spec.in
+++ b/freeipa.spec.in
@@ -221,6 +221,7 @@ BuildRequires:  python3-netaddr >= %{python_netaddr_version}
 BuildRequires:  python3-pyasn1
 BuildRequires:  python3-pyasn1-modules
 BuildRequires:  python3-six
+BuildRequires:  python3-psutil
 
 #
 # Build dependencies for wheel packaging and PyPI upload
@@ -418,6 +419,7 @@ Requires: python3-lxml
 Requires: python3-pki >= %{pki_version}
 Requires: python3-pyasn1 >= 0.3.2-2
 Requires: python3-sssdconfig >= %{sssd_version}
+Requires: python3-psutil
 Requires: rpm-libs
 # Indirect dependency: use newer urllib3 with TLS 1.3 PHA support
 %if 0%{?rhel}
diff --git a/ipaserver/install/installutils.py b/ipaserver/install/installutils.py
index a46acf9f5f..0e304a490a 100644
--- a/ipaserver/install/installutils.py
+++ b/ipaserver/install/installutils.py
@@ -18,6 +18,7 @@
 #
 
 from __future__ import absolute_import
+from __future__ import division
 from __future__ import print_function
 
 import logging
@@ -28,6 +29,7 @@
 import os
 import re
 import fileinput
+import psutil
 import sys
 import tempfile
 import shutil
@@ -975,6 +977,84 @@ def check_entropy():
         logger.debug("Invalid value in %s %s", paths.ENTROPY_AVAIL, e)
 
 
+def is_hidepid():
+    """Determine if /proc is mounted with hidepid=1/2 option"""
+    try:
+        os.lstat('/proc/1/stat')
+    except (FileNotFoundError, PermissionError):
+        return True
+    return False
+
+
+def in_container():
+    """Determine if we're running in a container.
+
+       virt-what will return the underlying machine information so
+       isn't usable here.
+
+       systemd-detect-virt requires the whole systemd subsystem which
+       isn't a reasonable require in a container.
+    """
+    if not is_hidepid():
+        with open('/proc/1/sched', 'r') as sched:
+            data_sched = sched.readline()
+    else:
+        data_sched = []
+
+    with open('/proc/self/cgroup', 'r') as cgroup:
+        data_cgroup = cgroup.readline()
+
+    checks = [
+        data_sched.split()[0] not in ('systemd', 'init',),
+        data_cgroup.split()[0] in ('libpod'),
+        os.path.exists('/.dockerenv'),
+        os.path.exists('/.dockerinit'),
+        os.getenv('container', None) is not None
+    ]
+
+    return any(checks)
+
+
+def check_available_memory(ca=False):
+    """
+    Raise an exception if there isn't enough memory for IPA to install.
+
+    In a container then psutil will most likely return the host memory
+    and not the container. If in a container use the cgroup values which
+    also may not be constrained but it's the best approximation.
+
+    2GB is the rule-of-thumb minimum but the server is installable with
+    less.
+
+    The CA uses ~150MB in a fresh install.
+
+    Use Kb instead of KiB to leave a bit of slush for the OS
+    """
+    minimum_suggested = 1000 * 1000 * 1000 * 1.6
+    if not ca:
+        minimum_suggested -= 150 * 1000 * 1000
+    if in_container():
+        if os.path.exists(
+            '/sys/fs/cgroup/memory/memory.limit_in_bytes'
+        ) and os.path.exists('/sys/fs/cgroup/memory/memory.usage_in_bytes'):
+            with open('/sys/fs/cgroup/memory/memory.limit_in_bytes') as fd:
+                limit = int(fd.readline())
+            with open('/sys/fs/cgroup/memory/memory.usage_in_bytes') as fd:
+                used = int(fd.readline())
+            available = limit - used
+        else:
+            raise ScriptError(
+                "Unable to determine the amount of available RAM"
+            )
+    else:
+        available = psutil.virtual_memory().available
+    logger.debug("Available memory is %sB", available)
+    if available < minimum_suggested:
+        raise ScriptError(
+            "Less than the minimum 1.6GB of RAM is available, "
+            "%.2fGB available" % (available / (1024 * 1024 * 1024))
+        )
+
 def load_external_cert(files, ca_subject):
     """
     Load and verify external CA certificate chain from multiple files.
diff --git a/ipaserver/install/server/__init__.py b/ipaserver/install/server/__init__.py
index 20b519a442..f9150ee4e5 100644
--- a/ipaserver/install/server/__init__.py
+++ b/ipaserver/install/server/__init__.py
@@ -330,6 +330,12 @@ def idmax(self):
     )
     dirsrv_config_file = enroll_only(dirsrv_config_file)
 
+    skip_mem_check = knob(
+        None,
+        description="Skip checking for minimum required memory",
+    )
+    skip_mem_check = enroll_only(skip_mem_check)
+
     @dirsrv_config_file.validator
     def dirsrv_config_file(self, value):
         if not os.path.exists(value):
diff --git a/ipaserver/install/server/install.py b/ipaserver/install/server/install.py
index 4d8e3ad78f..c551dfbeef 100644
--- a/ipaserver/install/server/install.py
+++ b/ipaserver/install/server/install.py
@@ -330,6 +330,8 @@ def install_check(installer):
     dirsrv_ca_cert = None
     pkinit_ca_cert = None
 
+    if not options.skip_mem_check:
+        installutils.check_available_memory(ca=options.setup_ca)
     tasks.check_ipv6_stack_enabled()
     tasks.check_selinux_status()
     check_ldap_conf()
diff --git a/ipaserver/install/server/replicainstall.py b/ipaserver/install/server/replicainstall.py
index c6c438a04a..f75f5fd99e 100644
--- a/ipaserver/install/server/replicainstall.py
+++ b/ipaserver/install/server/replicainstall.py
@@ -569,7 +569,9 @@ def check_remote_version(client, local_version):
             "the local version ({})".format(remote_version, local_version))
 
 
-def common_check(no_ntp):
+def common_check(no_ntp, skip_mem_check, setup_ca):
+    if not skip_mem_check:
+        installutils.check_available_memory(ca=setup_ca)
     tasks.check_ipv6_stack_enabled()
     tasks.check_selinux_status()
     check_ldap_conf()
@@ -776,7 +778,7 @@ def promote_check(installer):
     installer._top_dir = tempfile.mkdtemp("ipa")
 
     # check selinux status, http and DS ports, NTP conflicting services
-    common_check(options.no_ntp)
+    common_check(options.no_ntp, options.skip_mem_check, options.setup_ca)
 
     if options.setup_ca and any([options.dirsrv_cert_files,
                                  options.http_cert_files,

From 10b8cce4acc080c8208136b1dbca2dd16fbee6ce Mon Sep 17 00:00:00 2001
From: Rob Crittenden <rcritten@redhat.com>
Date: Thu, 27 Aug 2020 15:47:30 -0400
Subject: [PATCH 2/2] ipatests: Add tests for checking available memory

The tests always force container or no container so they should
run the same in any environment.

The following cases are handled:

- container, no cgroups
- container, insufficent RAM
- container, sufficient RAM for no CA
- container, insufficient RAM with CA
- non-container, sufficient RAM
- non-container, insufficient RAM

https://pagure.io/freeipa/issue/8404
---
 .../test_install/test_installutils.py         | 104 ++++++++++++++++++
 1 file changed, 104 insertions(+)

diff --git a/ipatests/test_ipaserver/test_install/test_installutils.py b/ipatests/test_ipaserver/test_install/test_installutils.py
index 739a64f470..cb4b88d06c 100644
--- a/ipatests/test_ipaserver/test_install/test_installutils.py
+++ b/ipatests/test_ipaserver/test_install/test_installutils.py
@@ -5,14 +5,18 @@
 
 import binascii
 import os
+import psutil
 import re
 import subprocess
 import textwrap
 
 import pytest
 
+from unittest.mock import patch, mock_open
+
 from ipaplatform.paths import paths
 from ipapython import ipautil
+from ipapython.admintool import ScriptError
 from ipaserver.install import installutils
 from ipaserver.install import ipa_backup
 from ipaserver.install import ipa_restore
@@ -156,3 +160,103 @@ def test_gpg_asymmetric(tempdir, gpgkey):
 def test_get_current_platform(monkeypatch, platform, expected):
     monkeypatch.setattr(installutils.ipaplatform, "NAME", platform)
     assert installutils.get_current_platform() == expected
+
+
+# The mock_exists in the following tests mocks that the cgroups
+# files exist even in non-containers. The values are provided by
+# mock_open_multi.
+
+
+@patch('ipaserver.install.installutils.in_container')
+@patch('os.path.exists')
+def test_in_container_no_cgroup(mock_exists, mock_in_container):
+    """
+    In a container in a container without cgroups, can't detect RAM
+    """
+    mock_in_container.return_value = True
+    mock_exists.side_effect = [False, False]
+    with pytest.raises(ScriptError):
+        installutils.check_available_memory(False)
+
+
+def mock_open_multi(*contents):
+    """Mock opening multiple files.
+
+       For our purposes the first read is limit, second is usage.
+
+       Note: this overrides *all* opens so if you use pdb then you will
+             need to extend the list by 2.
+    """
+    mock_files = [
+        mock_open(read_data=content).return_value for content in contents
+    ]
+    mock_multi = mock_open()
+    mock_multi.side_effect = mock_files
+
+    return mock_multi
+
+
+RAM_OK = str(1800 * 1000 * 1000)
+RAM_CA_USED = str(150 * 1000 * 1000)
+RAM_MOSTLY_USED = str(1500 * 1000 * 1000)
+RAM_NOT_OK = str(10 * 1000 * 1000)
+
+
+@patch('ipaserver.install.installutils.in_container')
+@patch('builtins.open', mock_open_multi(RAM_NOT_OK, "0"))
+@patch('os.path.exists')
+def test_in_container_insufficient_ram(mock_exists, mock_in_container):
+    """In a container with insufficient RAM and zero used"""
+    mock_in_container.return_value = True
+    mock_exists.side_effect = [True, True]
+
+    with pytest.raises(ScriptError):
+        installutils.check_available_memory(True)
+
+
+@patch('ipaserver.install.installutils.in_container')
+@patch('builtins.open', mock_open_multi(RAM_OK, RAM_CA_USED))
+@patch('os.path.exists')
+def test_in_container_ram_ok_no_ca(mock_exists, mock_in_container):
+    """In a container with just enough RAM to install w/o a CA"""
+    mock_in_container.return_value = True
+    mock_exists.side_effect = [True, True]
+
+    installutils.check_available_memory(False)
+
+
+@patch('ipaserver.install.installutils.in_container')
+@patch('builtins.open', mock_open_multi(RAM_OK, RAM_MOSTLY_USED))
+@patch('os.path.exists')
+def test_in_container_insufficient_ram_with_ca(mock_exists, mock_in_container):
+    """In a container and just miss the minimum RAM required"""
+    mock_in_container.return_value = True
+    mock_exists.side_effect = [True, True]
+
+    with pytest.raises(ScriptError):
+        installutils.check_available_memory(True)
+
+
+@patch('ipaserver.install.installutils.in_container')
+@patch('psutil.virtual_memory')
+def test_not_container_insufficient_ram_with_ca(mock_psutil, mock_in_container):
+    """Not a container and insufficient RAM"""
+    mock_in_container.return_value = False
+    fake_memory = psutil._pslinux.svmem
+    fake_memory.available = int(RAM_NOT_OK)
+    mock_psutil.return_value = fake_memory
+
+    with pytest.raises(ScriptError):
+        installutils.check_available_memory(True)
+
+
+@patch('ipaserver.install.installutils.in_container')
+@patch('psutil.virtual_memory')
+def test_not_container_ram_ok(mock_psutil, mock_in_container):
+    """Not a container and sufficient RAM"""
+    mock_in_container.return_value = False
+    fake_memory = psutil._pslinux.svmem
+    fake_memory.available = int(RAM_OK)
+    mock_psutil.return_value = fake_memory
+
+    installutils.check_available_memory(True)
