Repository: saltstack/pytest-salt-factories (language: python)

Issue:
Provide a method to get the `sshd` server host keys



Discussion / hints:

Fix patch:
diff --git a/changelog/176.bugfix.rst b/changelog/176.bugfix.rst
new file mode 100644
index 00000000..ddef0a5a
--- /dev/null
+++ b/changelog/176.bugfix.rst
@@ -0,0 +1,1 @@
+The `SSHD` daemon implementation now has a `get_host_keys` method which returns the host keys that can then be written to a ``known_hosts`` file.
diff --git a/changelog/177.trivial.rst b/changelog/177.trivial.rst
new file mode 100644
index 00000000..e7d22477
--- /dev/null
+++ b/changelog/177.trivial.rst
@@ -0,0 +1,6 @@
+CI pipeline changes:
+
+* Stop testing against Pytest 8.0.0rc2 and instead test against 8.0.x
+* Stop testing against Salt 3005.x
+* Add Salt 3007.x to the versions to test
+* Stop testing against Pytest 7.3.x and add 8.1.x to the list of versions to test
diff --git a/src/saltfactories/daemons/sshd.py b/src/saltfactories/daemons/sshd.py
index 25a87a7e..0deed01b 100644
--- a/src/saltfactories/daemons/sshd.py
+++ b/src/saltfactories/daemons/sshd.py
@@ -35,7 +35,9 @@ class Sshd(Daemon):
     display_name = attr.ib(default=None)
     client_key = attr.ib(default=None, init=False, repr=False)
     sshd_config = attr.ib(default=None, init=False)
+    _host_keys = attr.ib(init=False, repr=False, default=None)
     _ssh_keygen_path = attr.ib(default=shutil.which("ssh-keygen"))
+    _ssh_keyscan_path = attr.ib(default=shutil.which("ssh-keyscan"))
 
     def __attrs_post_init__(self):
         """
@@ -218,3 +220,43 @@ def _ssh_keygen(self, key_filename, key_type, bits, comment=None):
             )
             msg = "Failed to generate ssh key."
             raise FactoryFailure(msg, process_result=process_result) from exc
+
+    def get_host_keys(self):
+        """
+        Return a list of strings which are the SSHD server host keys.
+        """
+        if self._host_keys is None:
+            log.info("Collecting SSHD server keys ...")
+            cmdline = [
+                self._ssh_keyscan_path,
+                "-p",
+                str(self.listen_port),
+                "-t",
+                "rsa,dsa,ecdsa,ed25519",
+                self.listen_address,
+            ]
+            try:
+                ret = subprocess.run(
+                    cmdline,  # noqa: S603
+                    cwd=str(self.config_dir),
+                    check=True,
+                    text=True,
+                    capture_output=True,
+                )
+            except subprocess.CalledProcessError as exc:
+                process_result = ProcessResult(
+                    cmdline=exc.args,
+                    stdout=exc.stdout,
+                    stderr=exc.stderr,
+                    returncode=exc.returncode,
+                )
+                msg = "Failed to get the sshd server keys."
+                raise FactoryFailure(msg, process_result=process_result) from exc
+            else:
+                self._host_keys = []
+                for line in ret.stdout.splitlines():
+                    key = line.strip()
+                    if not key or key.startswith("#"):
+                        continue
+                    self._host_keys.append(key)
+        return self._host_keys
diff --git a/src/saltfactories/plugins/__init__.py b/src/saltfactories/plugins/__init__.py
index 6f2b26c0..01c23617 100644
--- a/src/saltfactories/plugins/__init__.py
+++ b/src/saltfactories/plugins/__init__.py
@@ -60,7 +60,9 @@ def pytest_load_initial_conftests(*_):
     if salt.version.__saltstack_version__ < "3005":
         msg = f"Only salt>=3005 is supported(Installed version {salt.version.__saltstack_version__}"
         raise pytest.UsageError(msg)
+    # pylint: disable=no-member
     if "temp_directory" not in pytest.helpers:
         pytest.helpers.register(saltfactories.utils.tempfiles.temp_directory, name="temp_directory")
     if "temp_file" not in pytest.helpers:
         pytest.helpers.register(saltfactories.utils.tempfiles.temp_file, name="temp_file")
+    # pylint: enable=no-member


Test patch:
diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 8580956b..4ff9b0d6 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -113,7 +113,7 @@ jobs:
       fail-fast: false
       matrix:
         pytest-version:
-          - "7.3.0"
+          - "7.4.0"
         salt-version:
           - "3006"
 
@@ -133,7 +133,7 @@ jobs:
 
     - name: Install Salt
       run: |
-        curl -L https://bootstrap.saltstack.com | sudo sh -s -- -M -X -x python3 ${{ matrix.salt-version == '3005' && 'old-stable' || 'stable' }} ${{ matrix.salt-version }}
+        curl -L https://bootstrap.saltstack.com | sudo sh -s -- -M -X -x python3 stable ${{ matrix.salt-version }}
         sudo apt-get install -y salt-api salt-ssh salt-syndic salt-cloud python3-pip
         for service in $(sudo systemctl list-unit-files | grep salt | grep -v @ | awk '{ print $1 }'); do sudo systemctl stop "$service"; done
 
@@ -184,7 +184,7 @@ jobs:
     runs-on: windows-latest
     needs: Pre-Commit
 
-    timeout-minutes: 40
+    timeout-minutes: 60
 
     strategy:
       fail-fast: false
@@ -196,10 +196,14 @@ jobs:
           - "3.10"
         salt-version:
           - "3006.0"
+          - "3007.0"
         pytest-version:
-          - "7.3.0"
           - "7.4.0"
-          - "8.0.0rc2"
+          - "8.0.0"
+          - "8.1.0"
+        exclude:
+          - {"salt-version": "3007.0", "python-version": "3.8"}
+          - {"salt-version": "3007.0", "python-version": "3.9"}
 
     steps:
     - uses: actions/checkout@v4
@@ -283,9 +287,9 @@ jobs:
         salt-version:
           - "3006.0"
         pytest-version:
-          - "7.3.0"
           - "7.4.0"
-          - "8.0.0rc2"
+          - "8.0.0"
+          - "8.1.0"
 
     steps:
     - uses: actions/checkout@v4
@@ -365,16 +369,18 @@ jobs:
           - "3.9"
           - "3.10"
         salt-version:
-          - "3005.0"
           - "3006.0"
+          - "3007.0"
         pytest-version:
-          - "7.3.0"
           - "7.4.0"
-          - "8.0.0rc2"
+          - "8.0.0"
+          - "8.1.0"
         exclude:
-          - {"python-version": "3.10", "salt-version": "3005.0"}
-          - {"pytest-version": "8.0.0rc2", "salt-version": "3005.0"}
-          - {"pytest-version": "8.0.0rc2", "python-version": "3.7"}
+          - {"pytest-version": "8.0.0", "python-version": "3.7"}
+          - {"pytest-version": "8.1.0", "python-version": "3.7"}
+          - {"salt-version": "3007.0", "python-version": "3.7"}
+          - {"salt-version": "3007.0", "python-version": "3.8"}
+          - {"salt-version": "3007.0", "python-version": "3.9"}
 
     steps:
     - uses: actions/checkout@v4
diff --git a/tests/integration/factories/daemons/ssh/test_salt_ssh.py b/tests/integration/factories/daemons/ssh/test_salt_ssh.py
index e6207210..2e8fbed1 100644
--- a/tests/integration/factories/daemons/ssh/test_salt_ssh.py
+++ b/tests/integration/factories/daemons/ssh/test_salt_ssh.py
@@ -1,9 +1,9 @@
 import logging
-import textwrap
 
 import pytest
 
 from saltfactories.utils import random_string
+from saltfactories.utils import tempfiles
 
 log = logging.getLogger(__name__)
 
@@ -32,30 +32,29 @@ def master(salt_factories):
 
 @pytest.fixture(scope="module")
 def salt_ssh_cli(sshd, salt_factories, master):
-    roster_file_path = salt_factories.tmp_root_dir / "salt_ssh_roster"
-    with open(str(roster_file_path), "w", encoding="utf-8") as wfh:
-        contents = textwrap.dedent(
-            f"""\
-        localhost:
-          host: 127.0.0.1
-          port: {sshd.listen_port}
-          mine_functions:
-            test.arg: ['itworked']
-        """
-        )
-        wfh.write(contents)
-        log.debug("Wrote '%s' with contents:\n%s", roster_file_path, contents)
-        log.warning("")
-    try:
+    roster_file_contents = f"""
+    localhost:
+        host: 127.0.0.1
+        port: {sshd.listen_port}
+        mine_functions:
+        test.arg: ['itworked']
+    """
+    # pylint: disable=no-member
+    with tempfiles.temp_file(
+        "salt_ssh_roster", roster_file_contents, salt_factories.tmp_root_dir
+    ) as roster_file_path, tempfiles.temp_file(
+        "known_hosts", "\n".join(sshd.get_host_keys()), salt_factories.tmp_root_dir
+    ) as known_hosts_file, tempfiles.temp_file(
+        "master.d/known-hosts.conf", f"known_hosts_file: {known_hosts_file}\n", master.config_dir
+    ):
         yield master.salt_ssh_cli(
             roster_file=str(roster_file_path), client_key=str(sshd.client_key)
         )
-    finally:
-        roster_file_path.unlink()
+    # pylint: enable=no-member
 
 
 @pytest.mark.skip_on_windows
 def test_salt_ssh(salt_ssh_cli):
-    ret = salt_ssh_cli.run("--ignore-host-keys", "test.echo", "It Works!", minion_tgt="localhost")
+    ret = salt_ssh_cli.run("test.echo", "It Works!", minion_tgt="localhost")
     assert ret.returncode == 0
     assert ret.data == "It Works!"
diff --git a/tests/integration/factories/daemons/sshd/test_sshd.py b/tests/integration/factories/daemons/sshd/test_sshd.py
index b30fb8a2..873a8dd2 100644
--- a/tests/integration/factories/daemons/sshd/test_sshd.py
+++ b/tests/integration/factories/daemons/sshd/test_sshd.py
@@ -3,15 +3,17 @@
 
 import pytest
 
+from saltfactories.utils import tempfiles
+
 pytestmark = [
     pytest.mark.skip_on_darwin,
     pytest.mark.skip_on_windows,
     pytest.mark.skip_on_salt_system_service,
+    pytest.mark.skip_if_binaries_missing("sshd", "ssh-keygen", "ssh-keyscan"),
 ]
 
 
 @pytest.fixture(scope="module")
-@pytest.mark.skip_if_binaries_missing("sshd", "ssh-keygen")
 def sshd(salt_factories):
     # Set StrictModes to no because our config directory lives in /tmp and those permissions
     # are not acceptable by sshd strict paranoia.
@@ -21,6 +23,14 @@ def sshd(salt_factories):
         yield factory
 
 
+@pytest.fixture(scope="module")
+def known_hosts_file(salt_factories, sshd):
+    with tempfiles.temp_file(
+        "known_hosts", "\n".join(sshd.get_host_keys()), salt_factories.tmp_root_dir
+    ) as known_hosts_file:
+        yield known_hosts_file
+
+
 @pytest.mark.skip_on_windows
 def test_sshd(sshd):
     assert sshd.is_running()
@@ -28,7 +38,7 @@ def test_sshd(sshd):
 
 @pytest.mark.skip_on_windows
 @pytest.mark.skip_if_binaries_missing("ssh")
-def test_connect(sshd):
+def test_connect(sshd, known_hosts_file):
     ssh = shutil.which("ssh")
     assert ssh is not None
     cmd = subprocess.run(
@@ -41,7 +51,7 @@ def test_connect(sshd):
             "-o",
             "StrictHostKeyChecking=no",
             "-o",
-            "UserKnownHostsFile=/dev/null",
+            f"UserKnownHostsFile={known_hosts_file}",
             sshd.listen_address,
             "echo Foo",
         ],