From 0e2569622be4578e611b9897ddf70319a692c9f1 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 2Gb of available RAM to install the
 server

Verify that there is at least 2Gb 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).

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

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

diff --git a/freeipa.spec.in b/freeipa.spec.in
index 43e063751b..9dfb27cd22 100755
--- a/freeipa.spec.in
+++ b/freeipa.spec.in
@@ -434,6 +434,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 a3274d5797..8c1319fc80 100644
--- a/ipaserver/install/installutils.py
+++ b/ipaserver/install/installutils.py
@@ -28,6 +28,7 @@
 import os
 import re
 import fileinput
+import psutil
 import sys
 import tempfile
 import shutil
@@ -972,6 +973,68 @@ def check_entropy():
         logger.debug("Invalid value in %s %s", paths.ENTROPY_AVAIL, e)
 
 
+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.
+    """
+    with open('/proc/1/sched', 'r') as sched:
+        data_sched = sched.readline()
+
+    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. 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 * 2
+    if not ca:
+        minimum_suggested -= 150 * 1000 * 1000
+    container = in_container()
+    logger.debug("Running in a container: %s", container)
+    if 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 recommended 2GB of RAM is available")
+
 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 381d86114a..07c9b6e583 100644
--- a/ipaserver/install/server/install.py
+++ b/ipaserver/install/server/install.py
@@ -343,6 +343,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 2990f4382d..22d3c01bbc 100644
--- a/ipaserver/install/server/replicainstall.py
+++ b/ipaserver/install/server/replicainstall.py
@@ -571,7 +571,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()
@@ -778,7 +780,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 31f6f82ab2b25a31be5bdb26604199c14231b92d 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         | 94 +++++++++++++++++++
 1 file changed, 94 insertions(+)

diff --git a/ipatests/test_ipaserver/test_install/test_installutils.py b/ipatests/test_ipaserver/test_install/test_installutils.py
index 739a64f470..809718a83b 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,93 @@ 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
+
+
+@patch('ipaserver.install.installutils.in_container')
+@patch('os.path.exists')
+def test_in_container_no_cgroup(mock_exists, mock_in_container):
+    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(2100 * 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 that lacks cgroup support"""
+    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, "200000000"))
+@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, "200000000"))
+@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)
