Repository: exasol/integration-test-docker-environment (language: python)

Issue:
Create SSH key for each user account
Currently the ITDE writes the SSH key to file `/tmp/ITDE/id_rsa` with permissions 600, i.e. readable only for the current user. 
Another user on the same (virtual) machine will find the file existing but cannot read it.

The current ticket therefore requests to select a different file location, enabling the file to exist on a per-user basis.
The user's home directory would be suitable in my eyes.

Create SSH key for each user account
Currently the ITDE writes the SSH key to file `/tmp/ITDE/id_rsa` with permissions 600, i.e. readable only for the current user. 
Another user on the same (virtual) machine will find the file existing but cannot read it.

The current ticket therefore requests to select a different file location, enabling the file to exist on a per-user basis.
The user's home directory would be suitable in my eyes.



Discussion / hints:
When using the the home directory of the current user: 
* see https://www.studytonight.com/python-howtos/how-to-get-the-home-directory-in-python
* `os.path.expanduser('~')`

When using the system's temp folder, then 
* see detect login name of current user, see https://stackoverflow.com/questions/842059/
* `import getpass; getpass.getuser()`


The implementation currently places the file with the private key for SSH in `/tmp`.
On system restart this folder will be emptied. 

The Java solution in [ETC](https://github.com/exasol/exasol-testcontainers) initially had used file in the build artifacts folder `./target` within the working copy of each project. But in January 2023 ETC switched to use the system global temp folder for the following reasons documented in file [changes_6.5.1.md](https://github.com/exasol/exasol-testcontainers/blob/main/doc/changes/changes_6.5.1.md). The [design document of the ETC](https://github.com/exasol/exasol-testcontainers/blob/main/doc/design.md) unfortunately does not name an explicit design decision. 

* make the credentials file survive clean builds with `mvn clean` 
* file is unchanged 
* Docker Container, its content, and its hash code remain unchanged 
* Docker Container can be reused 
* tests can be started faster especially when working with multiple projects.

In summary this sounds to me more like "we wanna keep the file" as like "we wanna ensure the file to vanish at some point in time".
Still we can discuss whether we want to prefer subfolders in the global system `/tmp` or in the home directory of the respective user.

Side note for windows users: Python `tempfile.gettempdir()` on windows 
* may return `C:\Users\<user>\AppData\Local\Temp`, see ticket [#302](https://github.com/exasol/integration-test-docker-environment/issues/302)
* which is per-user 🙂 
* and "temporary" (kind of)
* however might not be deleted on system restart, depending on the effective policies. 
* E.g. On my machine the file survives a restart.
When using the the home directory of the current user: 
* see https://www.studytonight.com/python-howtos/how-to-get-the-home-directory-in-python
* `os.path.expanduser('~')`

When using the system's temp folder, then 
* see detect login name of current user, see https://stackoverflow.com/questions/842059/
* `import getpass; getpass.getuser()`


The implementation currently places the file with the private key for SSH in `/tmp`.
On system restart this folder will be emptied. 

The Java solution in [ETC](https://github.com/exasol/exasol-testcontainers) initially had used file in the build artifacts folder `./target` within the working copy of each project. But in January 2023 ETC switched to use the system global temp folder for the following reasons documented in file [changes_6.5.1.md](https://github.com/exasol/exasol-testcontainers/blob/main/doc/changes/changes_6.5.1.md). The [design document of the ETC](https://github.com/exasol/exasol-testcontainers/blob/main/doc/design.md) unfortunately does not name an explicit design decision. 

* make the credentials file survive clean builds with `mvn clean` 
* file is unchanged 
* Docker Container, its content, and its hash code remain unchanged 
* Docker Container can be reused 
* tests can be started faster especially when working with multiple projects.

In summary this sounds to me more like "we wanna keep the file" as like "we wanna ensure the file to vanish at some point in time".
Still we can discuss whether we want to prefer subfolders in the global system `/tmp` or in the home directory of the respective user.

Side note for windows users: Python `tempfile.gettempdir()` on windows 
* may return `C:\Users\<user>\AppData\Local\Temp`, see ticket [#302](https://github.com/exasol/integration-test-docker-environment/issues/302)
* which is per-user 🙂 
* and "temporary" (kind of)
* however might not be deleted on system restart, depending on the effective policies. 
* E.g. On my machine the file survives a restart.

Fix patch:
diff --git a/doc/changes/changes_1.7.0.md b/doc/changes/changes_1.7.0.md
index fad7a5286..048f47060 100644
--- a/doc/changes/changes_1.7.0.md
+++ b/doc/changes/changes_1.7.0.md
@@ -7,6 +7,8 @@ Up to version 1.6.0 ITDE used `docker_exec` to access the Docker Container, e.g.
 The current release therefore enhances ITDE to enable to access the Docker Container via SSH.
 The user can select the docker access method with command line option `--db-os-access`, see User Guide.
 
+Additionally the directory for storing the randomly generated SSH keys has been moved to `~/.cache/exasol/itde/`. By that ITDE can restrict file permissions allowing access only by the current user.
+
 T.B.D.
 
 ### Supported Exasol Versions
@@ -24,3 +26,5 @@ If you need further versions, please open an issue.
 * #302: Added support to create an SSH key for accessing Docker Container
 * #241: Renamed environment variable for test execution from `GOOGLE_CLOUD_BUILD` to `RUN_SLC_TESTS_WITHIN_CONTAINER`
 * #190: Added support for the Exasol 8.0 Docker-DB prerelease version
+* #326: Changed folder for SSH keys to `~/.cache/exasol/itde/`
+* #303: Added authorized_keys to Docker Container for SSH access
diff --git a/doc/user_guide/user_guide.rst b/doc/user_guide/user_guide.rst
index 853894129..d94c33d3c 100644
--- a/doc/user_guide/user_guide.rst
+++ b/doc/user_guide/user_guide.rst
@@ -266,6 +266,29 @@ Currently supported Exasol Versions
 
 If you need further versions, please open an issue.
 
+
+SSH Access
+""""""""""
+
+Up to version 1.6.0 ITDE used ``docker_exec`` to access the operating system
+of the Exasol database inside the Docker Container, e.g. to analyze the
+content of logfiles or execute some shell commands. With version 8 of Exasol
+database the format of the Docker Containers might change so that
+``docker_exec`` is no longer possible. Instead ITDE will then need to use SSH
+access.
+
+You can select the access method with command line option
+``--db-os-access``. The default value is ``DOCKER_EXEC``.
+
+ITDE will create a random SSH key pair and store it to the file
+``~/.cache/exasol/itde/id_rsa`` with access permissions limited to the current
+user only. By this ITDE enables to reuse the same SSH keys for future sessions
+which leaves the container unchanged and hence reusable.
+
+The public key will be added as file ``/root/.ssh/authorized_keys`` inside the
+Docker Container to enable SSH access with public key authentication.
+
+
 Tested Docker Runtimes
 ~~~~~~~~~~~~~~~~~~~~~~
 


Test patch:
diff --git a/exasol_integration_test_docker_environment/lib/base/ssh_access.py b/exasol_integration_test_docker_environment/lib/base/ssh_access.py
index c9d0abb07..8da3a585d 100644
--- a/exasol_integration_test_docker_environment/lib/base/ssh_access.py
+++ b/exasol_integration_test_docker_environment/lib/base/ssh_access.py
@@ -10,20 +10,37 @@
 
 
 _LOCK_FILE = "$TMP/$MODULE-ssh-access.lock"
-_DEFAULT_FOLDER = "$TMP/$MODULE"
+_DEFAULT_CACHE_DIR = "$HOME/.cache/exasol/$MODULE"
 
 
 def _path(template: str) -> Path:
-    module_name = "integration-test-docker-environment"
     return Path(
         Template(template)
         .substitute(
             TMP=tempfile.gettempdir(),
-            MODULE=module_name,
+            HOME=os.path.expanduser('~'),
+            MODULE="itde",
         )
     )
 
 
+class SshKeyCache:
+    def __init__(self, directory: Optional[Path] = None):
+        self._directory = directory if directory else _path(_DEFAULT_CACHE_DIR)
+
+    @property
+    def directory(self) -> Path:
+        return self._directory
+
+    @property
+    def private_key(self) -> Path:
+        return self._directory / "id_rsa"
+
+    @property
+    def public_key(self) -> Path:
+        return self._directory / "id_rsa.pub"
+
+
 class SshKey:
     """This class hosts the private key for SSH access to a Docker Container
     and offers some convenience methods for writing the private or public key
@@ -33,14 +50,14 @@ class SshKey:
     The maker methods enable to either generate a new random key or to read it
     from a file, e.g. named "id_rsa".
 
-    When creating a new instance based on a folder containing a file id_rsa
+    When creating a new instance based on a directory containing a file id_rsa
     then the maker method will read the private key from this file.
 
-    If the folder does not contain this file then the maker method will
+    If the directory does not contain this file then the maker method will
     generate a new random SSH key, will write the private key to this file and
-    the public key to file id_rsa.pub in the same folder.
+    the public key to file id_rsa.pub in the same directory.
 
-    If the folder does not exist, then the maker method will create the folder
+    If the directory does not exist, then the maker method will create the directory
     and continue with generating a new random key.
 
     ITDE uses python library portalocker to guarantee that the key files are
@@ -58,8 +75,11 @@ def public_key_as_string(self, comment="") -> str:
         return f"ssh-rsa {b64} {comment}"
 
     def write_public_key(self, path: str, comment="") -> 'SshKey':
+        def opener(path, flags):
+            return os.open(path, flags, 0o600)
         content = self.public_key_as_string(comment)
-        with open(path, "w") as file:
+        # Windows does not support kwarg mode=0o600 here
+        with open(path, "w", opener=opener) as file:
             print(content, file=file)
         return self
 
@@ -75,24 +95,17 @@ def generate(cls) -> 'SshKey':
         return SshKey(rsa_key)
 
     @classmethod
-    def default_folder(cls) -> Path:
-        folder = _path(_DEFAULT_FOLDER)
-        # mode 0o700 = rwx permissions only for the current user
-        # is required for the folder to enable to create files inside
-        folder.mkdir(mode=0o700, exist_ok=True)
-        return folder
-
-    @classmethod
-    def from_folder(cls, folder: Optional[Path] = None) -> 'SshKey':
-        folder = folder if folder else cls.default_folder()
-        priv = folder / "id_rsa"
-        pub = folder / "id_rsa.pub"
-
+    def from_cache(cls, cache_directory: Path = None) -> 'SshKey':
+        cache = SshKeyCache(cache_directory)
+        priv = cache.private_key
         with portalocker.Lock(_path(_LOCK_FILE), 'wb', timeout=10) as fh:
             if priv.exists():
                 return cls.read_from(priv)
+            # mode 0o700 = rwx permissions only for the current user
+            # is required for the directory to enable to create files inside
+            os.makedirs(cache.directory, mode=0o700, exist_ok=True)
             return (
                 cls.generate()
                 .write_private_key(priv)
-                .write_public_key(pub)
+                .write_public_key(cache.public_key)
             )
diff --git a/exasol_integration_test_docker_environment/lib/test_environment/spawn_test_database.py b/exasol_integration_test_docker_environment/lib/test_environment/spawn_test_database.py
index 392c1e025..0f831b320 100644
--- a/exasol_integration_test_docker_environment/lib/test_environment/spawn_test_database.py
+++ b/exasol_integration_test_docker_environment/lib/test_environment/spawn_test_database.py
@@ -27,7 +27,10 @@
         DbOsAccess,
         DockerDBTestEnvironmentParameter,
 )
-from exasol_integration_test_docker_environment.lib.base.ssh_access import SshKey
+from exasol_integration_test_docker_environment.lib.base.ssh_access import (
+    SshKeyCache,
+    SshKey,
+)
 
 
 BUCKETFS_PORT = "6583"
@@ -80,12 +83,19 @@ def _try_to_reuse_database(self, db_ip_address: str) -> DatabaseInfo:
 
     def _handle_output(self, output_generator, image_info: ImageInfo):
         log_file_path = self.get_log_path().joinpath("pull_docker_db_image.log")
-        with PullLogHandler(log_file_path, self.logger, image_info) as log_hanlder:
+        with PullLogHandler(log_file_path, self.logger, image_info) as log_handler:
             still_running_logger = StillRunningLogger(
                 self.logger, "pull image %s" % image_info.get_source_complete_name())
             for log_line in output_generator:
                 still_running_logger.log()
-                log_hanlder.handle_log_lines(log_line)
+                log_handler.handle_log_lines(log_line)
+
+    def _enable_ssh_access(self, container: Container):
+        sshkey = SshKey.from_cache()
+        copy = DockerContainerCopy(container)
+        content = sshkey.public_key_as_string("itde-ssh-access")
+        copy.add_string_to_file(".ssh/authorized_keys", content)
+        copy.copy("/root/")
 
     def _create_database_container(self, db_ip_address: str, db_private_network: str):
         self.logger.info("Starting database container %s", self.db_container_name)
@@ -94,7 +104,7 @@ def _create_database_container(self, db_ip_address: str, db_private_network: str
                 docker_client.containers.get(self.db_container_name).remove(force=True, v=True)
             except:
                 pass
-            docker_db_image_info = self._pull_docker_db_images_if_necassary()
+            docker_db_image_info = self._pull_docker_db_images_if_necessary()
             db_volume = self._prepare_db_volume(docker_client, db_private_network, docker_db_image_info)
             ports = {}
             if self.database_port_forward is not None:
@@ -104,9 +114,6 @@ def _create_database_container(self, db_ip_address: str, db_private_network: str
             volumes = {db_volume.name: {"bind": "/exa", "mode": "rw"}}
             if self.certificate_volume_name is not None:
                 volumes[self.certificate_volume_name] = {"bind": CERTIFICATES_MOUNT_DIR, "mode": "ro"}
-
-            if self.db_os_access == DbOsAccess.SSH:
-                sshkey = SshKey.from_folder()
             db_container = \
                 docker_client.containers.create(
                     image="%s" % (docker_db_image_info.get_source_complete_name()),
@@ -118,6 +125,8 @@ def _create_database_container(self, db_ip_address: str, db_private_network: str
                     ports=ports,
                     runtime=self.docker_runtime
                 )
+            if self.db_os_access == DbOsAccess.SSH:
+                self._enable_ssh_access(db_container)
             docker_network = docker_client.networks.get(self.network_info.network_name)
             network_aliases = self._get_network_aliases()
             docker_network.connect(db_container, ipv4_address=db_ip_address, aliases=network_aliases)
@@ -146,7 +155,7 @@ def _create_database_info(self, db_ip_address: str, reused: bool) -> DatabaseInf
                              reused=reused, container_info=container_info)
             return database_info
 
-    def _pull_docker_db_images_if_necassary(self):
+    def _pull_docker_db_images_if_necessary(self):
         image_name = "exasol/docker-db"
         docker_db_image_info = ImageInfo(
             target_repository_name=image_name,
@@ -170,65 +179,72 @@ def _pull_docker_db_images_if_necassary(self):
 
     def _prepare_db_volume(self, docker_client, db_private_network: str,
                            docker_db_image_info: ImageInfo) -> Volume:
-        db_volume_name = self._get_db_volume_name()
-        db_volume_preperation_container_name = self._get_db_volume_preperation_container_name()
-        self._remove_container(db_volume_preperation_container_name)
-        self._remove_volume(db_volume_name)
-        db_volume, volume_preparation_container = \
-            volume_preparation_container, volume_preparation_container = \
-            self._create_volume_and_container(docker_client, db_volume_name,
-                                              db_volume_preperation_container_name)
+        volume, container = self._prepare_volume(
+            docker_client,
+            self._get_db_volume_name(),
+            self._get_db_volume_preparation_container_name(),
+            remove_old_instances=True,
+        )
         try:
-            self._upload_init_db_files(volume_preparation_container,
-                                       db_private_network)
-            self._execute_init_db(db_volume, volume_preparation_container)
-            return db_volume
+            self._upload_init_db_files(container, db_private_network)
+            self._execute_init_db(volume, container)
+            return volume
         finally:
-            volume_preparation_container.remove(force=True)
+            container.remove(force=True)
 
-    def _get_db_volume_preperation_container_name(self):
-        db_volume_preperation_container_name = f"""{self.db_container_name}_preparation"""
-        return db_volume_preperation_container_name
+    def _get_db_volume_preparation_container_name(self):
+        return f"""{self.db_container_name}_preparation"""
 
     def _get_db_volume_name(self):
-        db_volume_name = f"""{self.db_container_name}_volume"""
-        return db_volume_name
+        return f"""{self.db_container_name}_volume"""
 
-    def _remove_container(self, db_volume_preperation_container_name):
+    def _remove_container(self, container_name:str):
         try:
             with self._get_docker_client() as docker_client:
-                docker_client.containers.get(db_volume_preperation_container_name).remove(force=True)
-                self.logger.info("Removed container %s", db_volume_preperation_container_name)
+                docker_client.containers.get(container_name).remove(force=True)
+                self.logger.info("Removed container %s", container_name)
         except docker.errors.NotFound:
             pass
 
-    def _remove_volume(self, db_volume_name):
+    def _remove_volume(self, volume_name:str):
         try:
             with self._get_docker_client() as docker_client:
-                docker_client.volumes.get(db_volume_name).remove(force=True)
-                self.logger.info("Removed volume %s", db_volume_name)
+                docker_client.volumes.get(volume_name).remove(force=True)
+                self.logger.info("Removed volume %s", volume_name)
         except docker.errors.NotFound:
             pass
 
-    def _create_volume_and_container(self, docker_client, db_volume_name, db_volume_preperation_container_name) \
-            -> Tuple[Volume, Container]:
-        db_volume = docker_client.volumes.create(db_volume_name)
-        volume_preparation_container = \
-            docker_client.containers.run(
+    def _prepare_volume(
+            self,
+            docker_client: docker.api.APIClient,
+            volume_name,
+            container_name,
+            remove_old_instances: bool = False,
+    ) -> Tuple[Volume, Container]:
+        """
+        Create an intermediate Docker Container containing a volume that
+        can be mounted into another Docker Container.
+        """
+        if remove_old_instances:
+            self._remove_container(container_name)
+            self._remove_volume(volume_name)
+        volume = docker_client.volumes.create(volume_name)
+        container = docker_client.containers.run(
                 image="ubuntu:18.04",
-                name=db_volume_preperation_container_name,
+                name=container_name,
                 auto_remove=True,
                 command="sleep infinity",
                 detach=True,
-                volumes={
-                    db_volume.name: {"bind": "/exa", "mode": "rw"}},
-                labels={"test_environment_name": self.environment_name, "container_type": "db_container"})
-        return db_volume, volume_preparation_container
+                volumes={volume.name: {"bind": "/exa", "mode": "rw"}},
+                labels={
+                    "test_environment_name": self.environment_name,
+                    "container_type": "db_volume_preparation_container",
+                }
+        )
+        return volume, container
 
-    def _upload_init_db_files(self,
-                              volume_preperation_container: Container,
-                              db_private_network: str):
-        copy = DockerContainerCopy(volume_preperation_container)
+    def _upload_init_db_files(self, container: Container, db_private_network: str):
+        copy = DockerContainerCopy(container)
         init_db_script_str = pkg_resources.resource_string(
             PACKAGE_NAME,
             f"{self.docker_db_config_resource_name}/init_db.sh") # type: bytes
@@ -256,7 +272,7 @@ def _add_exa_conf(self, copy: DockerContainerCopy,
                                             additional_db_parameters=additional_db_parameter_str)
         copy.add_string_to_file("EXAConf", rendered_template)
 
-    def _execute_init_db(self, db_volume: Volume, volume_preperation_container: Container):
+    def _execute_init_db(self, db_volume: Volume, preparation_container: Container):
         disk_size_in_bytes = humanfriendly.parse_size(self.disk_size)
         min_overhead_in_gigabyte = 2  # Exasol needs at least a 2 GB larger device than the configured disk size
         overhead_factor = max(0.01, (
@@ -266,20 +282,20 @@ def _execute_init_db(self, db_volume: Volume, volume_preperation_container: Cont
             device_size_in_bytes / (1024 * 1024))  # The init_db.sh script works with MB, because its faster
         self.logger.info(
             f"Creating database volume of size {device_size_in_megabytes / 1024} GB using and overhead factor of {overhead_factor}")
-        (exit_code, output) = volume_preperation_container.exec_run(cmd=f"bash /init_db.sh {device_size_in_megabytes}")
+        (exit_code, output) = preparation_container.exec_run(cmd=f"bash /init_db.sh {device_size_in_megabytes}")
         if exit_code != 0:
             raise Exception(
-                "Error during preperation of docker-db volume %s got following output %s" % (db_volume.name, output))
+                "Error during preparation of docker-db volume %s got following output %s" % (db_volume.name, output))
 
     def cleanup_task(self, success):
         if (success and not self.no_database_cleanup_after_success) or \
                 (not success and not self.no_database_cleanup_after_failure):
-            db_volume_preperation_container_name = self._get_db_volume_preperation_container_name()
+            volume_container = self._get_db_volume_preparation_container_name()
             try:
-                self.logger.info(f"Cleaning up container %s", db_volume_preperation_container_name)
-                self._remove_container(db_volume_preperation_container_name)
+                self.logger.info(f"Cleaning up container %s", volume_container)
+                self._remove_container(volume_container)
             except Exception as e:
-                self.logger.error(f"Error during removing container %s: %s", db_volume_preperation_container_name, e)
+                self.logger.error(f"Error during removing container %s: %s", volume_container, e)
 
             try:
                 self.logger.info(f"Cleaning up container %s", self.db_container_name)
@@ -289,7 +305,7 @@ def cleanup_task(self, success):
 
             db_volume_name = self._get_db_volume_name()
             try:
-                self.logger.info(f"Cleaning up docker volumne %s", db_volume_name)
+                self.logger.info(f"Cleaning up docker volume %s", db_volume_name)
                 self._remove_volume(db_volume_name)
             except Exception as e:
                 self.logger.error(f"Error during removing docker volume %s: %s", db_volume_name, e)
diff --git a/exasol_integration_test_docker_environment/test/conftest.py b/exasol_integration_test_docker_environment/test/conftest.py
deleted file mode 100644
index 65c2f8b20..000000000
--- a/exasol_integration_test_docker_environment/test/conftest.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import contextlib
-import logging
-import pytest
-from typing import Callable, Iterator, List, Optional
-
-from exasol_integration_test_docker_environment.testing import utils
-from exasol_integration_test_docker_environment.testing \
-   .exaslct_test_environment import (
-       ExaslctTestEnvironment,
-       SpawnedTestEnvironments,
-   )
-
-
-@pytest.fixture
-def cli_isolation(request) -> Iterator[ExaslctTestEnvironment]:
-    testname = request.node.name
-    environment = ExaslctTestEnvironment(
-        test_object=None,
-        executable="itde",
-        clean_images_at_close=True,
-        name=testname,
-    )
-    try:
-        yield environment
-    finally:
-        utils.close_environments(environment)
-
-
-@pytest.fixture
-def database(cli_isolation) -> Callable:
-    """
-    Returns a method that test case implementations can use to create a
-    context with a database.
-
-    The test case optionally can pass a name and additional parameters for
-    spawning the database:
-
-    def test_case(database):
-        with database(additional_parameters = ["--option"]):
-            ...
-    """
-    @contextlib.contextmanager
-    def create_context(
-            name: Optional[str] = None,
-            additional_parameters: Optional[List[str]] = None,
-    ):
-        name = name if name else cli_isolation.name
-        spawned = cli_isolation.spawn_docker_test_environments(
-            name=name,
-            additional_parameter=additional_parameters,
-        )
-        try:
-            yield spawned
-        finally:
-            utils.close_environments(spawned)
-    return create_context
diff --git a/exasol_integration_test_docker_environment/test/test_ssh_access.py b/exasol_integration_test_docker_environment/test/test_ssh_access.py
deleted file mode 100644
index f902c4282..000000000
--- a/exasol_integration_test_docker_environment/test/test_ssh_access.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import pytest
-
-from exasol_integration_test_docker_environment.lib.base.ssh_access import SshKey
-
-
-def test_generate_ssh_key_file(database):
-    with database(
-            additional_parameters = ["--db-os-access", "SSH"],
-    ):
-        file = SshKey.default_folder() / "id_rsa"
-    assert file.exists()
diff --git a/exasol_integration_test_docker_environment/testing/api_test_environment.py b/exasol_integration_test_docker_environment/testing/api_test_environment.py
index ff9a7f35e..f4114c6da 100644
--- a/exasol_integration_test_docker_environment/testing/api_test_environment.py
+++ b/exasol_integration_test_docker_environment/testing/api_test_environment.py
@@ -11,30 +11,27 @@
 from exasol_integration_test_docker_environment.lib.data.test_container_content_description import \
     TestContainerContentDescription
 from exasol_integration_test_docker_environment.testing.docker_registry import default_docker_repository_name
-from exasol_integration_test_docker_environment.testing.exaslct_docker_test_environment import \
+from exasol_integration_test_docker_environment \
+    .testing.exaslct_docker_test_environment import \
     ExaslctDockerTestEnvironment
+from exasol_integration_test_docker_environment \
+    .testing.exaslct_test_environment import (
+        get_class,
+        get_test_flavor,
+)
 from exasol_integration_test_docker_environment.testing.utils import find_free_ports
 
 
 class ApiTestEnvironment:
 
-    def __init__(self, test_object):
+    def __init__(self, test_object, name=None):
         self.test_object = test_object
-        if not inspect.isclass(self.test_object):
-            self.test_class = self.test_object.__class__
-        else:
-            self.test_class = self.test_object
-        self.flavor_path = self.get_test_flavor()
-        self.name = self.test_class.__name__
+        self.test_class = get_class(test_object)
+        self.flavor_path = get_test_flavor(self.test_class)
+        self.name = name if name else self.test_class.__name__
         self.docker_repository_name = default_docker_repository_name(self.name)
         self.temp_dir = tempfile.mkdtemp()
 
-    def get_test_flavor(self):
-        source_file_of_test_object = inspect.getsourcefile(self.test_class)
-        flavor_path = Path(os.path.realpath(source_file_of_test_object)).parent.joinpath(
-            "resources/test-flavor")
-        return flavor_path
-
     @property
     def output_dir(self):
         return self.temp_dir
diff --git a/exasol_integration_test_docker_environment/testing/exaslct_test_environment.py b/exasol_integration_test_docker_environment/testing/exaslct_test_environment.py
index 94ba39ce9..31724d858 100644
--- a/exasol_integration_test_docker_environment/testing/exaslct_test_environment.py
+++ b/exasol_integration_test_docker_environment/testing/exaslct_test_environment.py
@@ -29,10 +29,18 @@ def _cleanup(env_name: str):
 def get_class(test_object):
     if test_object is None:
         return None
-    if not inspect.isclass(test_object):
-        # test_object is an instance -> return its class
-        return  test_object.__class__
-    return test_object
+    # if test_object is a class then simply return it.
+    # Otherwise test_object is an instance of a class, then
+    # return its class.
+    return test_object if inspect.isclass(test_object) else test_object.__class__
+
+
+def get_test_flavor(test_class):
+    if test_class is None:
+        return None
+    source_file_of_test_object = inspect.getsourcefile(test_class)
+    return Path(os.path.realpath(source_file_of_test_object)).parent.joinpath(
+        "resources/test-flavor")
 
 
 class ExaslctTestEnvironment:
@@ -42,7 +50,7 @@ def __init__(self, test_object, executable="./exaslct", clean_images_at_close=Tr
         self.executable = executable
         self.test_object = test_object
         self.test_class = get_class(test_object)
-        self.flavor_path = self.get_test_flavor()
+        self.flavor_path = get_test_flavor(self.test_class)
         self.name = name if name else self.test_class.__name__
         self._docker_repository_name = default_docker_repository_name(self.name)
         if "RUN_SLC_TESTS_WITHIN_CONTAINER" in os.environ:
@@ -57,14 +65,6 @@ def __init__(self, test_object, executable="./exaslct", clean_images_at_close=Tr
             self.temp_dir = tempfile.mkdtemp()
         self._update_attributes()
 
-    def get_test_flavor(self):
-        if self.test_class is None:
-            return None
-        source_file_of_test_object = inspect.getsourcefile(self.test_class)
-        flavor_path = Path(os.path.realpath(source_file_of_test_object)).parent.joinpath(
-            "resources/test-flavor")
-        return flavor_path
-
     @property
     def repository_name(self):
         return self._docker_repository_name
@@ -75,7 +75,7 @@ def repository_name(self, value):
         self._update_attributes()
 
     def _update_attributes(self):
-        self.flavor_path_argument = f"--flavor-path {self.get_test_flavor()}"
+        self.flavor_path_argument = f"--flavor-path {self.flavor_path}"
         repository_name = self.repository_name
         self.docker_repository_arguments = f"--source-docker-repository-name {repository_name} " \
                                            f"--target-docker-repository-name {repository_name}"
diff --git a/test/integration/conftest.py b/test/integration/conftest.py
new file mode 100644
index 000000000..2956a0610
--- /dev/null
+++ b/test/integration/conftest.py
@@ -0,0 +1,115 @@
+import contextlib
+import logging
+import pytest
+from typing import Any, Callable, Dict, Iterator, List, NewType, Optional
+
+from exasol_integration_test_docker_environment \
+    .testing.api_test_environment import ApiTestEnvironment
+from exasol_integration_test_docker_environment \
+    .testing.exaslct_docker_test_environment import \
+    ExaslctDockerTestEnvironment
+from exasol_integration_test_docker_environment.lib.docker import ContextDockerClient
+from exasol_integration_test_docker_environment.testing import utils
+from exasol_integration_test_docker_environment.testing \
+   .exaslct_test_environment import (
+       ExaslctTestEnvironment,
+       SpawnedTestEnvironments,
+   )
+
+
+@pytest.fixture
+def cli_isolation(request) -> Iterator[ExaslctTestEnvironment]:
+    testname = request.node.name
+    environment = ExaslctTestEnvironment(
+        test_object=None,
+        executable="itde",
+        clean_images_at_close=True,
+        name=testname,
+    )
+    yield environment
+    utils.close_environments(environment)
+
+
+@pytest.fixture
+def api_isolation(request) -> Iterator[ApiTestEnvironment]:
+    testname = request.node.name
+    environment = ApiTestEnvironment(test_object=None, name=testname)
+    yield environment
+    utils.close_environments(environment)
+
+
+CliContextProvider = NewType(
+    "CliContextProvider", Callable[
+        [Optional[str], Optional[List[str]]],
+        SpawnedTestEnvironments
+    ],
+)
+
+@pytest.fixture
+def cli_database(cli_isolation) -> CliContextProvider:
+    """
+    Returns a method that test case implementations can use to create a
+    context with a database.
+
+    The test case optionally can pass a name and additional parameters for
+    spawning the database:
+
+    def test_case(database):
+        with database(additional_parameters = ["--option"]):
+            ...
+    """
+    @contextlib.contextmanager
+    def create_context(
+            name: Optional[str] = None,
+            additional_parameters: Optional[List[str]] = None,
+    )->SpawnedTestEnvironments:
+        name = name if name else cli_isolation.name
+        spawned = cli_isolation.spawn_docker_test_environments(
+            name=name,
+            additional_parameter=additional_parameters,
+        )
+        yield spawned
+        utils.close_environments(spawned)
+    return create_context
+
+
+ApiContextProvider = NewType(
+    "ApiContextProvider",
+    Callable[
+        [Optional[str], Optional[Dict[str, Any]]],
+        ExaslctDockerTestEnvironment
+    ],
+)
+
+
+@pytest.fixture
+def api_database(api_isolation: ApiTestEnvironment) -> ApiContextProvider:
+    @contextlib.contextmanager
+    def create_context(
+            name: Optional[str] = None,
+            additional_parameters: Optional[Dict[str, Any]] = None,
+    )->ExaslctDockerTestEnvironment:
+        name = name if name else api_isolation.name
+        spawned = api_isolation.spawn_docker_test_environment(
+            name=name,
+            additional_parameter=additional_parameters,
+        )
+        yield spawned
+        utils.close_environments(spawned)
+    return create_context
+
+
+def exact_matcher(names):
+    return lambda value: all(x == value for x in names)
+
+
+def superset_matcher(names):
+    return lambda value: all(x in value for x in names)
+
+
+@contextlib.contextmanager
+def container_named(*names, matcher=None):
+    matcher = matcher if matcher else exact_matcher(names)
+    with ContextDockerClient() as client:
+        matches = [c for c in client.containers.list() if matcher(c.name)]
+        yield matches[0] if matches else None
diff --git a/test/integration/test_ssh_access.py b/test/integration/test_ssh_access.py
new file mode 100644
index 000000000..9f445e356
--- /dev/null
+++ b/test/integration/test_ssh_access.py
@@ -0,0 +1,16 @@
+import os
+import pytest
+
+from conftest import container_named
+from exasol_integration_test_docker_environment.lib.base.ssh_access import SshKeyCache
+
+
+def test_generate_ssh_key_file(api_database):
+    params = { "db_os_access": "SSH" }
+    with api_database(additional_parameters=params) as db:
+        cache = SshKeyCache()
+        container_name = db.environment_info.database_info.container_info.container_name
+        with container_named(container_name) as container:
+            command = container.exec_run("cat /root/.ssh/authorized_keys")
+    assert cache.private_key.exists()
+    " itde-ssh-access" in command[1].decode("utf-8")
diff --git a/test/unit/test_base_ssh_keys.py b/test/unit/test_base_ssh_keys.py
index c7fd22d1c..8cd9804b7 100644
--- a/test/unit/test_base_ssh_keys.py
+++ b/test/unit/test_base_ssh_keys.py
@@ -1,15 +1,14 @@
 import os
 import platform
-import sys
 import pytest
 
 from pathlib import Path
-from exasol_integration_test_docker_environment.lib.base.ssh_access import SshKey
+from exasol_integration_test_docker_environment.lib.base.ssh_access import SshKeyCache, SshKey
 
 
 def test_create_file_and_permissions(tmp_path):
-    testee = SshKey.from_folder(tmp_path)
-    file = tmp_path / "id_rsa"
+    SshKey.from_cache(tmp_path)
+    file = SshKeyCache(tmp_path).private_key
     assert file.exists()
     actual = oct(os.stat(file).st_mode)[-3:]
     expected = "666" if platform.system() == "Windows" else "600"
@@ -17,7 +16,6 @@ def test_create_file_and_permissions(tmp_path):
 
 
 def test_read_existing_file(tmp_path):
-    testee = SshKey.from_folder(tmp_path)
-    other = SshKey.from_folder(tmp_path)
+    testee = SshKey.from_cache(tmp_path)
+    other = SshKey.from_cache(tmp_path)
     assert testee.private == other.private
-