File use-salt-bundle-in-dockermod.patch of Package venv-salt-minion

From b0891f83afa354c4b1f803af8a679ecf5a7fb63c Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <vzhestkov@suse.com>
Date: Mon, 27 Jun 2022 17:59:24 +0300
Subject: [PATCH] Use Salt Bundle in dockermod

* Use Salt Bundle for salt calls in dockermod

* Add test of performing a call with the Salt Bundle
---
 salt/modules/dockermod.py                     | 197 +++++++++++++++---
 .../unit/modules/dockermod/test_module.py     |  78 ++++++-
 2 files changed, 241 insertions(+), 34 deletions(-)

diff --git a/salt/modules/dockermod.py b/salt/modules/dockermod.py
index 6870c26b0e..8b6ab8058e 100644
--- a/salt/modules/dockermod.py
+++ b/salt/modules/dockermod.py
@@ -201,14 +201,19 @@ import copy
 import fnmatch
 import functools
 import gzip
+import hashlib
 import json
 import logging
 import os
+import pathlib
 import pipes
 import re
 import shutil
 import string
 import subprocess
+import sys
+import tarfile
+import tempfile
 import time
 import uuid
 
@@ -6698,6 +6703,111 @@ def _compile_state(sls_opts, mods=None):
         return st_.state.compile_high_data(high_data)
 
 
+def gen_venv_tar(cachedir, venv_dest_dir, venv_name):
+    """
+    Generate tarball with the Salt Bundle if required and return the path to it
+    """
+    exec_path = pathlib.Path(sys.executable).parts
+    venv_dir_name = "venv-salt-minion"
+    if venv_dir_name not in exec_path:
+        return None
+
+    venv_tar = os.path.join(cachedir, "venv-salt.tgz")
+    venv_hash = os.path.join(cachedir, "venv-salt.hash")
+    venv_lock = os.path.join(cachedir, ".venv-salt.lock")
+
+    venv_path = os.path.join(*exec_path[0 : exec_path.index(venv_dir_name)])
+
+    with __utils__["files.flopen"](venv_lock, "w"):
+        start_dir = os.getcwd()
+        venv_hash_file = os.path.join(venv_path, venv_dir_name, "venv-hash.txt")
+        try:
+            with __utils__["files.fopen"](venv_hash_file, "r") as fh:
+                venv_hash_src = fh.readline().strip()
+        except Exception:  # pylint: disable=broad-except
+            # It makes no sense what caused the exception
+            # Just calculate the hash different way
+            for cmd in ("rpm -qi venv-salt-minion", "dpkg -s venv-salt-minion"):
+                ret = __salt__["cmd.run_all"](
+                    cmd,
+                    python_shell=True,
+                    clean_env=True,
+                    env={"LANG": "C", "LANGUAGE": "C", "LC_ALL": "C"},
+                )
+                if ret.get("retcode") == 0 and ret.get("stdout"):
+                    venv_hash_src = hashlib.sha256(
+                        "{}\n".format(ret.get("stdout")).encode()
+                    ).hexdigest()
+                    break
+        try:
+            with __utils__["files.fopen"](venv_hash, "r") as fh:
+                venv_hash_dest = fh.readline().strip()
+        except Exception:  # pylint: disable=broad-except
+            # It makes no sense what caused the exception
+            # Set the hash to impossible value to force new tarball creation
+            venv_hash_dest = "UNKNOWN"
+        if venv_hash_src == venv_hash_dest and os.path.isfile(venv_tar):
+            return venv_tar
+        try:
+            tfd, tmp_venv_tar = tempfile.mkstemp(
+                dir=cachedir,
+                prefix=".venv-",
+                suffix=os.path.splitext(venv_tar)[1],
+            )
+            os.close(tfd)
+
+            os.chdir(venv_path)
+            tfp = tarfile.open(tmp_venv_tar, "w:gz")
+
+            for root, dirs, files in salt.utils.path.os_walk(
+                venv_dir_name, followlinks=True
+            ):
+                for name in files:
+                    if name == "python" and pathlib.Path(root).parts == (
+                        venv_dir_name,
+                        "bin",
+                    ):
+                        tfd, tmp_python_file = tempfile.mkstemp(
+                            dir=cachedir,
+                            prefix=".python-",
+                        )
+                        os.close(tfd)
+                        try:
+                            with __utils__["files.fopen"](
+                                os.path.join(root, name), "r"
+                            ) as fh_in:
+                                with __utils__["files.fopen"](
+                                    tmp_python_file, "w"
+                                ) as fh_out:
+                                    rd_lines = fh_in.readlines()
+                                    rd_lines = [
+                                        'export VIRTUAL_ENV="{}"\n'.format(
+                                            os.path.join(venv_dest_dir, venv_name)
+                                        )
+                                        if line.startswith("export VIRTUAL_ENV=")
+                                        else line
+                                        for line in rd_lines
+                                    ]
+                                    fh_out.write("".join(rd_lines))
+                            os.chmod(tmp_python_file, 0o755)
+                            tfp.add(tmp_python_file, arcname=os.path.join(root, name))
+                            continue
+                        finally:
+                            if os.path.isfile(tmp_python_file):
+                                os.remove(tmp_python_file)
+                    if not name.endswith((".pyc", ".pyo")):
+                        tfp.add(os.path.join(root, name))
+
+            tfp.close()
+            shutil.move(tmp_venv_tar, venv_tar)
+            with __utils__["files.fopen"](venv_hash, "w") as fh:
+                fh.write("{}\n".format(venv_hash_src))
+        finally:
+            os.chdir(start_dir)
+
+    return venv_tar
+
+
 def call(name, function, *args, **kwargs):
     """
     Executes a Salt function inside a running container
@@ -6733,47 +6843,68 @@ def call(name, function, *args, **kwargs):
     if function is None:
         raise CommandExecutionError("Missing function parameter")
 
-    # move salt into the container
-    thin_path = __utils__["thin.gen_thin"](
-        __opts__["cachedir"],
-        extra_mods=__salt__["config.option"]("thin_extra_mods", ""),
-        so_mods=__salt__["config.option"]("thin_so_mods", ""),
-    )
-    ret = copy_to(
-        name, thin_path, os.path.join(thin_dest_path, os.path.basename(thin_path))
-    )
+    venv_dest_path = "/var/tmp"
+    venv_name = "venv-salt-minion"
+    venv_tar = gen_venv_tar(__opts__["cachedir"], venv_dest_path, venv_name)
 
-    # figure out available python interpreter inside the container (only Python3)
-    pycmds = ("python3", "/usr/libexec/platform-python")
-    container_python_bin = None
-    for py_cmd in pycmds:
-        cmd = [py_cmd] + ["--version"]
-        ret = run_all(name, subprocess.list2cmdline(cmd))
-        if ret["retcode"] == 0:
-            container_python_bin = py_cmd
-            break
-    if not container_python_bin:
-        raise CommandExecutionError(
-            "Python interpreter cannot be found inside the container. Make sure Python is installed in the container"
+    if venv_tar is not None:
+        venv_python_bin = os.path.join(venv_dest_path, venv_name, "bin", "python")
+        dest_venv_tar = os.path.join(venv_dest_path, os.path.basename(venv_tar))
+        copy_to(name, venv_tar, dest_venv_tar, overwrite=True, makedirs=True)
+        run_all(
+            name,
+            subprocess.list2cmdline(
+                ["tar", "zxf", dest_venv_tar, "-C", venv_dest_path]
+            ),
+        )
+        run_all(name, subprocess.list2cmdline(["rm", "-f", dest_venv_tar]))
+        container_python_bin = venv_python_bin
+        thin_dest_path = os.path.join(venv_dest_path, venv_name)
+        thin_salt_call = os.path.join(thin_dest_path, "bin", "salt-call")
+    else:
+        # move salt into the container
+        thin_path = __utils__["thin.gen_thin"](
+            __opts__["cachedir"],
+            extra_mods=__salt__["config.option"]("thin_extra_mods", ""),
+            so_mods=__salt__["config.option"]("thin_so_mods", ""),
         )
 
-    # untar archive
-    untar_cmd = [
-        container_python_bin,
-        "-c",
-        'import tarfile; tarfile.open("{0}/{1}").extractall(path="{0}")'.format(
-            thin_dest_path, os.path.basename(thin_path)
-        ),
-    ]
-    ret = run_all(name, subprocess.list2cmdline(untar_cmd))
-    if ret["retcode"] != 0:
-        return {"result": False, "comment": ret["stderr"]}
+        ret = copy_to(
+            name, thin_path, os.path.join(thin_dest_path, os.path.basename(thin_path))
+        )
+
+        # figure out available python interpreter inside the container (only Python3)
+        pycmds = ("python3", "/usr/libexec/platform-python")
+        container_python_bin = None
+        for py_cmd in pycmds:
+            cmd = [py_cmd] + ["--version"]
+            ret = run_all(name, subprocess.list2cmdline(cmd))
+            if ret["retcode"] == 0:
+                container_python_bin = py_cmd
+                break
+        if not container_python_bin:
+            raise CommandExecutionError(
+                "Python interpreter cannot be found inside the container. Make sure Python is installed in the container"
+            )
+
+        # untar archive
+        untar_cmd = [
+            container_python_bin,
+            "-c",
+            'import tarfile; tarfile.open("{0}/{1}").extractall(path="{0}")'.format(
+                thin_dest_path, os.path.basename(thin_path)
+            ),
+        ]
+        ret = run_all(name, subprocess.list2cmdline(untar_cmd))
+        if ret["retcode"] != 0:
+            return {"result": False, "comment": ret["stderr"]}
+        thin_salt_call = os.path.join(thin_dest_path, "salt-call")
 
     try:
         salt_argv = (
             [
                 container_python_bin,
-                os.path.join(thin_dest_path, "salt-call"),
+                thin_salt_call,
                 "--metadata",
                 "--local",
                 "--log-file",
diff --git a/tests/pytests/unit/modules/dockermod/test_module.py b/tests/pytests/unit/modules/dockermod/test_module.py
index 8fb7806497..1ac7dff52a 100644
--- a/tests/pytests/unit/modules/dockermod/test_module.py
+++ b/tests/pytests/unit/modules/dockermod/test_module.py
@@ -3,6 +3,7 @@ Unit tests for the docker module
 """
 
 import logging
+import sys
 
 import pytest
 
@@ -26,6 +27,7 @@ def configure_loader_modules(minion_opts):
         whitelist=[
             "args",
             "docker",
+            "files",
             "json",
             "state",
             "thin",
@@ -880,13 +882,16 @@ def test_call_success():
     client = Mock()
     client.put_archive = Mock()
     get_client_mock = MagicMock(return_value=client)
+    gen_venv_tar_mock = MagicMock(return_value=None)
 
     context = {"docker.exec_driver": "docker-exec"}
     salt_dunder = {"config.option": docker_config_mock}
 
     with patch.object(docker_mod, "run_all", docker_run_all_mock), patch.object(
         docker_mod, "copy_to", docker_copy_to_mock
-    ), patch.object(docker_mod, "_get_client", get_client_mock), patch.dict(
+    ), patch.object(docker_mod, "_get_client", get_client_mock), patch.object(
+        docker_mod, "gen_venv_tar", gen_venv_tar_mock
+    ), patch.dict(
         docker_mod.__opts__, {"cachedir": "/tmp"}
     ), patch.dict(
         docker_mod.__salt__, salt_dunder
@@ -931,6 +936,11 @@ def test_call_success():
         != docker_run_all_mock.mock_calls[9][1][1]
     )
 
+    # check the parameters of gen_venv_tar call
+    assert gen_venv_tar_mock.mock_calls[0][1][0] == "/tmp"
+    assert gen_venv_tar_mock.mock_calls[0][1][1] == "/var/tmp"
+    assert gen_venv_tar_mock.mock_calls[0][1][2] == "venv-salt-minion"
+
     assert {"retcode": 0, "comment": "container cmd"} == ret
 
 
@@ -1352,3 +1362,69 @@ def test_port():
             "bar": {"6666/tcp": ports["bar"]["6666/tcp"]},
             "baz": {},
         }
+
+
+@pytest.mark.slow_test
+def test_call_with_gen_venv_tar():
+    """
+    test module calling inside containers with the Salt Bundle
+    """
+    ret = None
+    docker_run_all_mock = MagicMock(
+        return_value={
+            "retcode": 0,
+            "stdout": '{"retcode": 0, "comment": "container cmd"}',
+            "stderr": "err",
+        }
+    )
+    docker_copy_to_mock = MagicMock(return_value={"retcode": 0})
+    docker_config_mock = MagicMock(return_value="")
+    docker_cmd_run_mock = MagicMock(
+        return_value={
+            "retcode": 0,
+            "stdout": "test",
+        }
+    )
+    client = Mock()
+    client.put_archive = Mock()
+    get_client_mock = MagicMock(return_value=client)
+
+    context = {"docker.exec_driver": "docker-exec"}
+    salt_dunder = {
+        "config.option": docker_config_mock,
+        "cmd.run_all": docker_cmd_run_mock,
+    }
+
+    with patch.object(docker_mod, "run_all", docker_run_all_mock), patch.object(
+        docker_mod, "copy_to", docker_copy_to_mock
+    ), patch.object(docker_mod, "_get_client", get_client_mock), patch.object(
+        sys, "executable", "/tmp/venv-salt-minion/bin/python"
+    ), patch.dict(
+        docker_mod.__opts__, {"cachedir": "/tmp"}
+    ), patch.dict(
+        docker_mod.__salt__, salt_dunder
+    ), patch.dict(
+        docker_mod.__context__, context
+    ):
+        ret = docker_mod.call("ID", "test.arg", 1, 2, arg1="val1")
+
+    # Check that the directory is different each time
+    # [ call(name, [args]), ...
+    assert "mkdir" in docker_run_all_mock.mock_calls[0][1][1]
+
+    assert (
+        "tar zxf /var/tmp/venv-salt.tgz -C /var/tmp"
+        == docker_run_all_mock.mock_calls[1][1][1]
+    )
+
+    assert docker_run_all_mock.mock_calls[3][1][1].startswith(
+        "/var/tmp/venv-salt-minion/bin/python /var/tmp/venv-salt-minion/bin/salt-call "
+    )
+
+    # check remove the salt bundle tarball
+    assert docker_run_all_mock.mock_calls[2][1][1] == "rm -f /var/tmp/venv-salt.tgz"
+
+    # check directory cleanup
+    assert docker_run_all_mock.mock_calls[4][1][1] == "rm -rf /var/tmp/venv-salt-minion"
+
+    assert {"retcode": 0, "comment": "container cmd"} == ret
-- 
2.39.2


openSUSE Build Service is sponsored by