Quick Start Guide

This guide will show you how to setup and extend the pytest-mh plugin. We will write a simple test of Kerberos authentication that spans over two separate hosts - one host has the Kerberos KDC running and the other host will be used as a client machine.

Note

The complete code is located in the example directory of pytest-mh repository.

See also

A real life example of how pytest-mh can help to test your code can be seen in the SSSD project.

All projects are different, therefore pytest_mh plugin provides only the most basic functionality like ssh access to hosts and building blocks to build your own tools and API. It is expected that you implement required functionality in host, role and utility classes by extending MultihostHost, MultihostRole and MultihostUtility.

Since pytest_mh plugin is fully extensible, it is possible to also add your own configuration options and different domain types by extending MultihostConfig and MultihostDomain. This step is actually required as the base classes are abstract and you have to overwrite specific methods and properties in order to give a list of your own domain, host and role classes that will be automatically be instantiated by the plugin.

Note

The difference between host, roles, and utility classes:

  • Host classes are created only once before the first test is executed and exist during the whole pytest session. They can be used to setup everything that should live for the whole session.

  • Role classes are the main objects that are directly accessible from individual tests. They are created just before the test execution and destroyed once the test is finished. They can perform setup required to run the tests and proper clean up after the test is finished. Roles should also define and implement proper API to access required resources.

  • Utility classes are instantiated inside individual roles. They represent functionality that can be shared between roles. They are also responsible to clean up every change that is done through their API. The pytest_mh plugin already has some utility classes bundled within, see pytest_mh.utils.

Create configuration and domain classes

First of all, we need to extend MultihostConfig and tell it how to create our own domain object. Additionally, we need to extend MultihostDomain and define a mapping between role name and host classes and also a mapping between role name and role classes. This tells the plugin which host and role classes should be instantiated for given role.

In the example below, we define two roles: “client” and “kdc”. Each role has its own role (client, KDC) and host class (ClientHost, KDCHost).

/lib/config.py
 1from __future__ import annotations
 2
 3from typing import Type
 4
 5from pytest_mh import MultihostConfig, MultihostDomain, MultihostHost, MultihostRole
 6
 7
 8class ExampleMultihostConfig(MultihostConfig):
 9    @property
10    def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]:
11        """
12        Map domain id to domain class. Asterisk ``*`` can be used as fallback
13        value.
14
15        :rtype: Class name.
16        """
17        return {"*": ExampleMultihostDomain}
18
19
20class ExampleMultihostDomain(MultihostDomain[ExampleMultihostConfig]):
21    @property
22    def role_to_host_class(self) -> dict[str, Type[MultihostHost]]:
23        """
24        Map role to host class. Asterisk ``*`` can be used as fallback value.
25
26        :rtype: Class name.
27        """
28        from .hosts.client import ClientHost
29        from .hosts.kdc import KDCHost
30
31        return {
32            "client": ClientHost,
33            "kdc": KDCHost,
34        }
35
36    @property
37    def role_to_role_class(self) -> dict[str, Type[MultihostRole]]:
38        """
39        Map role to role class. Asterisk ``*`` can be used as fallback value.
40
41        :rtype: Class name.
42        """
43        from .roles.client import Client
44        from .roles.kdc import KDC
45
46        return {
47            "client": Client,
48            "kdc": KDC,
49        }

Note

It is not necessary to create distinct role and host class for every role. The classes can be shared for multiple roles if it makes sense for your project.

Create host classes

KDC Host

The KDC host takes care of backup and restore of the KDC data. It create backup of KDC database when pytest is started and restores it to the original state every time a test is finished. This ensures that the database is always the same for each test execution. It also removes the backup file when pytest is terminated.

/lib/hosts/kdc.py
 1from __future__ import annotations
 2
 3from pytest_mh import MultihostHost
 4from pytest_mh.ssh import SSHPowerShellProcess
 5
 6from ..config import ExampleMultihostDomain
 7
 8
 9class KDCHost(MultihostHost[ExampleMultihostDomain]):
10    """
11    Kerberos KDC server host object.
12
13    Provides features specific to Kerberos KDC.
14
15    .. note::
16
17        Full backup and restore is supported.
18    """
19
20    def __init__(self, *args, **kwargs) -> None:
21        super().__init__(*args, **kwargs)
22
23        self.__backup_location: str | None = None
24        """Backup file or folder location."""
25
26    def pytest_setup(self) -> None:
27        """
28        Called once before execution of any tests.
29        """
30        super().setup()
31
32        # Backup KDC data
33        self.ssh.run('kdb5_util dump /tmp/mh.kdc.kdb.backup && rm -f "/tmp/mh.kdc.kdb.backup.dump_ok"')
34        self.__backup_location = "/tmp/mh.kdc.kdb.backup"
35
36    def pytest_teardown(self) -> None:
37        """
38        Called once after all tests are finished.
39        """
40        # Remove backup file
41        if self.__backup_location is not None:
42            if self.ssh.shell is SSHPowerShellProcess:
43                self.ssh.exec(["Remove-Item", "-Force", "-Recurse", self.__backup_location])
44            else:
45                self.ssh.exec(["rm", "-fr", self.__backup_location])
46
47        super().teardown()
48
49    def teardown(self) -> None:
50        """
51        Called after execution of each test.
52        """
53        # Restore KDC data to its original state
54        self.ssh.run(f'kdb5_util load "{self.__backup_location}"')
55        super().teardown()

Client Host

The client host does not perform any backup and restore as it is not needed, but it reads additional configuration values from the multihost configuration (mhc.yaml) file.

Note

The additional configuration is read from the standard config field which is there for this very reason. But if it makes sense, you can of course extend any section.

/lib/hosts/client.py
 1from __future__ import annotations
 2
 3from pytest_mh import MultihostHost
 4
 5from ..config import ExampleMultihostDomain
 6
 7
 8class ClientHost(MultihostHost[ExampleMultihostDomain]):
 9    """
10    Kerberos client host object.
11
12    Provides features specific to Kerberos client.
13
14    This class adds ``config.realm``, ``config.krbdomain`` and ``config.kdc``
15    multihost configuration options to set the default kerberos realm,
16    domain and the kdc hostname.
17
18    .. code-block:: yaml
19        :caption: Example multihost configuration
20        :emphasize-lines: 6-8
21
22        - hostname: client.test
23          role: client
24          config:
25            realm: TEST
26            krbdomain: test
27            kdc: kdc.test
28
29    .. note::
30
31        Full backup and restore is supported.
32    """
33
34    def __init__(self, *args, **kwargs) -> None:
35        super().__init__(*args, **kwargs)
36
37        self.realm: str = self.config.get("realm", "TEST")
38        self.krbdomain: str = self.config.get("krbdomain", "test")
39        self.kdc: str = self.config.get("kdc", "kdc.test")

Create role classes

Unlike hosts, the role classes are the right place to provide all functionality that will help you write good tests so they are usually quite complex.

KDC Role

The KDC class implements the functionality desired for “kdc” role. In this example, we focus on adding the Kerberos principal (or Kerberos user if you are not familiar with Kerberos terminology) and querying the kadmin tool to get some additional information.

/lib/roles/kdc.py
  1from __future__ import annotations
  2
  3from pytest_mh import MultihostRole
  4from pytest_mh.ssh import SSHProcessResult
  5
  6from ..hosts.kdc import KDCHost
  7
  8
  9class KDC(MultihostRole[KDCHost]):
 10    """
 11    Kerberos KDC role.
 12
 13    Provides unified Python API for managing objects in the Kerberos KDC.
 14
 15    .. code-block:: python
 16        :caption: Creating user and group
 17
 18        @pytest.mark.topology(KnownTopology.KDC)
 19        def test_example(kdc: KDC):
 20            kdc.principal('tuser').add()
 21
 22    .. note::
 23
 24        The role object is instantiated automatically as a dynamic pytest
 25        fixture by the multihost plugin. You should not create the object
 26        manually.
 27    """
 28
 29    def __init__(self, *args, **kwargs) -> None:
 30        super().__init__(*args, **kwargs)
 31
 32    def kadmin(self, command: str) -> SSHProcessResult:
 33        """
 34        Run kadmin command on the KDC.
 35
 36        :param command: kadmin command
 37        :type command: str
 38        """
 39        result = self.host.ssh.exec(["kadmin.local", "-q", command])
 40
 41        # Remove "Authenticating as principal root/admin@TEST with password."
 42        # from the output and keep only output of the command itself.
 43        result.stdout_lines = result.stdout_lines[1:]
 44        result.stdout = "\n".join(result.stdout_lines)
 45
 46        return result
 47
 48    def list_principals(self) -> list[str]:
 49        """
 50        List existing Kerberos principals.
 51
 52        :return: List of Kerberos principals.
 53        :rtype: list[str]
 54        """
 55        result = self.kadmin("listprincs")
 56        return result.stdout_lines
 57
 58    def principal(self, name: str) -> KDCPrincipal:
 59        """
 60        Get Kerberos principal object.
 61
 62        .. code-block:: python
 63            :caption: Example usage
 64
 65            @pytest.mark.topology(KnownTopology.KDC)
 66            def test_example(client: Client, kdc: KDC):
 67                kdc.principal('tuser').add()
 68
 69        :param name: Principal name.
 70        :type name: str
 71        :return: New principal object.
 72        :rtype: KDCPrincipal
 73        """
 74        return KDCPrincipal(self, name)
 75
 76
 77class KDCPrincipal(object):
 78    """
 79    Kerberos principals management.
 80    """
 81
 82    def __init__(self, role: KDC, name: str) -> None:
 83        """
 84        :param role: KDC role object.
 85        :type role: KDC
 86        :param name: Principal name.
 87        :type name: str
 88        """
 89        self.role: KDC = role
 90        """KDC role."""
 91
 92        self.name: str = name
 93        """Principal name."""
 94
 95    def add(self, *, password: str | None = "Secret123") -> KDCPrincipal:
 96        """
 97        Add a new Kerberos principal.
 98
 99        Random password is generated if ``password`` is ``None``.
100
101        :param password: Principal's password, defaults to 'Secret123'
102        :type password: str | None
103        :return: Self.
104        :rtype: KDCPrincipal
105        """
106        if password is not None:
107            self.role.kadmin(f'addprinc -pw "{password}" "{self.name}"')
108        else:
109            self.role.kadmin(f'addprinc -randkey "{self.name}"')
110
111        return self
112
113    def get(self) -> dict[str, str]:
114        """
115        Retrieve principal information.
116
117        :return: Principal information.
118        :rtype: dict[str, str]
119        """
120        result = self.role.kadmin(f'getprinc "{self.name}"')
121        out = {}
122        for line in result.stdout_lines:
123            (key, value) = line.split(":", maxsplit=1)
124            out[key] = value.strip()
125
126        return out
127
128    def delete(self) -> None:
129        """
130        Delete existing Kerberos principal.
131        """
132        self.role.kadmin(f'delprinc -force "{self.name}"')
133
134    def set_string(self, key: str, value: str) -> KDCPrincipal:
135        """
136        Set principal's string attribute.
137
138        :param key: Attribute name.
139        :type key: str
140        :param value: Atribute value.
141        :type value: str
142        :return: Self.
143        :rtype: KDCPrincipal
144        """
145        self.role.kadmin(f'setstr "{self.name}" "{key}" "{value}"')
146        return self
147
148    def get_strings(self) -> dict[str, str]:
149        """
150        Get all principal's string attributes.
151
152        :return: String attributes.
153        :rtype: dict[str, str]
154        """
155        result = self.role.kadmin(f'getstrs "{self.name}"')
156        out = {}
157        for line in result.stdout_lines:
158            (key, value) = line.split(":", maxsplit=1)
159            out[key] = value.strip()
160
161        return out
162
163    def get_string(self, key: str) -> str | None:
164        """
165        Set principal's string attribute.
166
167        :param key: Attribute name.
168        :type key: str
169        :return: Attribute's value or None if not found.
170        :rtype: str | None
171        """
172        attrs = self.get_strings()
173
174        return attrs.get(key, None)

Client Role

The client role first creates /etc/krb5.conf so the Kerberos client knows what KDC we want to use. For this, it uses the bundle LinuxFileSystem utility class, which writes the file to the remote path and when a test is finished, it makes sure to restore the original content or remove the file if it was not present before.

/lib/roles/client.py
  1"""Client multihost role."""
  2
  3from __future__ import annotations
  4
  5import textwrap
  6
  7from pytest_mh import MultihostRole
  8from pytest_mh.ssh import SSHProcessError, SSHProcessResult
  9from pytest_mh.utils.fs import LinuxFileSystem
 10
 11from ..hosts.client import ClientHost
 12
 13
 14class Client(MultihostRole[ClientHost]):
 15    """
 16    Kerberos client role.
 17
 18    Provides unified Python API for managing and testing Kerberos client.
 19
 20    .. note::
 21
 22        The role object is instantiated automatically as a dynamic pytest
 23        fixture by the multihost plugin. You should not create the object
 24        manually.
 25    """
 26
 27    def __init__(self, *args, **kwargs) -> None:
 28        super().__init__(*args, **kwargs)
 29
 30        self.realm: str = self.host.realm
 31        """
 32        Kerberos realm.
 33        """
 34
 35        self.fs: LinuxFileSystem = LinuxFileSystem(self.host)
 36        """
 37        File system manipulation.
 38        """
 39
 40    def setup(self) -> None:
 41        """
 42        Called before execution of each test.
 43
 44        Setup client host:
 45
 46        #. Create krb5.conf
 47
 48        .. note::
 49
 50            Original krb5.conf is automatically restored when the test is finished.
 51        """
 52        super().setup()
 53        config = textwrap.dedent(
 54            f"""
 55            [logging]
 56            default = FILE:/var/log/krb5libs.log
 57            kdc = FILE:/var/log/krb5kdc.log
 58            admin_server = FILE:/var/log/kadmind.log
 59
 60            [libdefaults]
 61            default_realm = {self.host.realm}
 62            default_ccache_name = KCM:
 63            dns_lookup_realm = false
 64            dns_lookup_kdc = false
 65            ticket_lifetime = 24h
 66            renew_lifetime = 7d
 67            forwardable = yes
 68
 69            [realms]
 70            {self.host.realm} = {{
 71              kdc = {self.host.kdc}:88
 72              admin_server = {self.host.kdc}:749
 73              max_life = 7d
 74              max_renewable_life = 14d
 75            }}
 76
 77            [domain_realm]
 78            .{self.host.krbdomain} = {self.host.realm}
 79            {self.host.krbdomain} = {self.host.realm}
 80        """
 81        ).lstrip()
 82        self.fs.write("/etc/krb5.conf", config, user="root", group="root", mode="0644")
 83
 84    def kinit(
 85        self, principal: str, *, password: str, realm: str | None = None, args: list[str] | None = None
 86    ) -> SSHProcessResult:
 87        """
 88        Run ``kinit`` command.
 89
 90        Principal can be without the realm part. The realm can be given in
 91        separate parameter ``realm``, in such case the principal name is
 92        constructed as ``$principal@$realm``. If the principal does not contain
 93        realm specification and ``realm`` parameter is not set then the default
 94        realm is used.
 95
 96        :param principal: Kerberos principal.
 97        :type principal: str
 98        :param password: Principal's password.
 99        :type password: str
100        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None
101        :type realm: str | None, optional
102        :param args: Additional parameters to ``klist``, defaults to None
103        :type args: list[str] | None, optional
104        :return: Command result.
105        :rtype: SSHProcessResult
106        """
107        if args is None:
108            args = []
109
110        if realm is not None:
111            principal = f"{principal}@{realm}"
112
113        return self.host.ssh.exec(["kinit", *args, principal], input=password)
114
115    def kvno(self, principal: str, *, realm: str | None = None, args: list[str] | None = None) -> SSHProcessResult:
116        """
117        Run ``kvno`` command.
118
119        Principal can be without the realm part. The realm can be given in
120        separate parameter ``realm``, in such case the principal name is
121        constructed as ``$principal@$realm``. If the principal does not contain
122        realm specification and ``realm`` parameter is not set then the default
123        realm is used.
124
125        :param principal: Kerberos principal.
126        :type principal: str
127        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None
128        :type realm: str | None, optional
129        :param args: Additional parameters to ``klist``, defaults to None
130        :type args: list[str] | None, optional
131        :return: Command result.
132        :rtype: SSHProcessResult
133        """
134        if args is None:
135            args = []
136
137        if realm is not None:
138            principal = f"{principal}@{realm}"
139
140        return self.host.ssh.exec(["kvno", *args, principal])
141
142    def klist(self, *, args: list[str] | None = None) -> SSHProcessResult:
143        """
144        Run ``klist`` command.
145
146        :param args: Additional parameters to ``klist``, defaults to None
147        :type args: list[str] | None, optional
148        :return: Command result.
149        :rtype: SSHProcessResult
150        """
151        if args is None:
152            args = []
153
154        return self.host.ssh.exec(["klist", *args])
155
156    def kswitch(self, principal: str, realm: str) -> SSHProcessResult:
157        """
158        Run ``kswitch -p principal@realm`` command.
159
160        :param principal: Kerberos principal.
161        :type principal: str
162        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``)
163        :type realm: str
164        :return: Command result.
165        :rtype: SSHProcessResult
166        """
167        if "@" not in principal:
168            principal = f"{principal}@{realm}"
169
170        return self.host.ssh.exec(["kswitch", "-p", principal])
171
172    def kdestroy(
173        self, *, all: bool = False, ccache: str | None = None, principal: str | None = None, realm: str | None = None
174    ) -> SSHProcessResult:
175        """
176        Run ``kdestroy`` command.
177
178        Principal can be without the realm part. The realm can be given in
179        separate parameter ``realm``, in such case the principal name is
180        constructed as ``$principal@$realm``. If the principal does not contain
181        realm specification and ``realm`` parameter is not set then the default
182        realm is used.
183
184        :param all: Destroy all ccaches (``kdestroy -A``), defaults to False
185        :type all: bool, optional
186        :param ccache: Destroy specific ccache (``kdestroy -c $cache``), defaults to None
187        :type ccache: str | None, optional
188        :param principal: Destroy ccache for given principal (``kdestroy -p $princ``), defaults to None
189        :type principal: str | None, optional
190        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None
191        :type realm: str | None, optional
192        :return: Command result.
193        :rtype: SSHProcessResult
194        """
195        args = []
196
197        if all:
198            args.append("-A")
199
200        if ccache is not None:
201            args.append("-c")
202            args.append(ccache)
203
204        if realm is not None and principal is not None:
205            principal = f"{principal}@{realm}"
206
207        if principal is not None:
208            args.append("-p")
209            args.append(principal)
210
211        return self.host.ssh.exec(["kdestroy", *args])
212
213    def has_tgt(self, realm: str) -> bool:
214        """
215        Check that the user has obtained Kerberos Ticket Granting Ticket.
216
217        :param realm: Expected realm for which the TGT was obtained.
218        :type realm: str
219        :return: True if TGT is available, False otherwise.
220        :rtype: bool
221        """
222        try:
223            result = self.klist()
224        except SSHProcessError:
225            return False
226
227        return f"krbtgt/{realm}@{realm}" in result.stdout

Define multihost topology

Each test is associated with one or more topologies. A topology defines multihost requirements that must be met in order to run the test. If the requirements are not met, the test will not run. These requirements are:

  • What domains are available

  • What roles and how many roles inside each domain are available

To assign a topology to a test case, we use @pytest.mark.topology(...). The next example defines a topology with one domain that contains one client and one kdc role. Hosts that implements these roles are then available as pytest fixtures.

@pytest.mark.topology(
    "kdc", Topology(TopologyDomain("test", client=1, kdc=1)),
    fixtures=dict(client="test.client[0]", kdc="test.kdc[0]")
)
def test_example(client: Client, kdc: KDC):
    pass

However, this can be little bit cumbersome, therefore it is good practice to define a list of known topologies first.

/lib/topology.py
 1from __future__ import annotations
 2
 3from enum import unique
 4from typing import final
 5
 6from pytest_mh import KnownTopologyBase, Topology, TopologyDomain, TopologyMark
 7
 8
 9@final
10@unique
11class KnownTopology(KnownTopologyBase):
12    """
13    Well-known topologies that can be given to ``pytest.mark.topology``
14    directly. It is expected to use these values in favor of providing
15    custom marker values.
16
17    .. code-block:: python
18        :caption: Example usage
19
20        @pytest.mark.topology(KnownTopology.KDC)
21        def test_kdc(client: Client, kdc: KDC):
22            assert True
23    """
24
25    KDC = TopologyMark(
26        name="kdc",
27        topology=Topology(TopologyDomain("test", client=1, kdc=1)),
28        fixtures=dict(client="test.client[0]", kdc="test.kdc[0]"),
29    )

Now we can shorten the topology marker like this:

@pytest.mark.topology(KnownTopology.KDC)
def test_example(client: Client, kdc: KDC):
    pass

See also

There is also KnownTopologyGroupBase to define a list of topologies that should be assigned to the test case and thus create topology parameterization.

Create multihost configuration

Now, our test framework is ready to use. We just need to provide multihost configuration file that defines available hosts.

We set custom fields that are required by ClientHost and we also define list of artifacts that are automatically fetched from the remote host.

/mhc.yaml
 1domains:
 2- id: test
 3  hosts:
 4  - hostname: client.test
 5    role: client
 6    config:
 7      realm: TEST
 8      krbdomain: test
 9      kdc: kdc.test
10
11  - hostname: kdc.test
12    role: kdc
13    artifacts:
14    - /var/log/krb5kdc.log

Note

The example configuration assumes running containers from sssd-ci-containers project.

Enable pytest-mh in pytest

The pytest-mh plugin needs to be manually enabled in conftest.py and it needs to know the configuration class that should be instantiated.

/conftest.py
 1# Configuration file for multihost tests.
 2
 3from __future__ import annotations
 4
 5from lib.config import ExampleMultihostConfig
 6
 7from pytest_mh import MultihostPlugin
 8
 9# Load additional plugins
10pytest_plugins = ("pytest_mh",)
11
12
13# Setup pytest-mh
14def pytest_plugin_registered(plugin) -> None:
15    if isinstance(plugin, MultihostPlugin):
16        plugin.config_class = ExampleMultihostConfig

Write and run a simple test

All the pieces are now available. We have successfully setup the pytest-mh plugin, created our own test framework API. Now it is time to write some tests.

/tests/test_kdc.py
 1from __future__ import annotations
 2
 3import pytest
 4from lib.roles.client import Client
 5from lib.roles.kdc import KDC
 6from lib.topology import KnownTopology
 7
 8
 9@pytest.mark.topology(KnownTopology.KDC)
10def test_kinit(client: Client, kdc: KDC):
11    kdc.principal("user-1").add(password="Secret123")
12
13    client.kinit("user-1", realm=client.realm, password="Secret123")
14    assert client.has_tgt(client.realm)
15
16    client.kdestroy()
17    assert not client.has_tgt(client.realm)
18
19
20@pytest.mark.topology(KnownTopology.KDC)
21def test_kvno(client: Client, kdc: KDC):
22    kdc.principal("user-1").add(password="Secret123")
23    kdc.principal("host/myhost").add()
24
25    client.kinit("user-1", realm=client.realm, password="Secret123")
26    assert client.has_tgt(client.realm)
27
28    client.kvno("host/myhost", realm=client.realm)
29    assert "host/myhost" in client.klist().stdout

Now we can run them. Notice how the topology name is mentioned in the test name.

$ pytest --mh-config=./mhc.yaml -vv
Multihost configuration:
domains:
- id: test
    hosts:
    - hostname: client.test
      role: client
      config:
        realm: TEST
        krbdomain: test
        kdc: kdc.test
    - hostname: kdc.test
      role: kdc
      artifacts:
      - /var/log/krb5kdc.log

Detected topology:
- id: test
    hosts:
    client: 1
    kdc: 1

Additional settings:
config file: ./mhc.yaml
log path: None
lazy ssh: False
topology filter: None
require exact topology: False
collect artifacts: on-failure
artifacts directory: ./artifacts

============================================================================================================ test session starts =============================================================================================================
platform linux -- Python 3.10.8, pytest-7.2.1, pluggy-1.0.0 -- /home/pbrezina/workspace/pytest-mh/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /home/pbrezina/workspace/pytest-mh, configfile: pytest.ini
collected 2 items

tests/test_kdc.py::test_kinit (kdc) PASSED                                                                                                                                                                                             [ 50%]
tests/test_kdc.py::test_kvno (kdc) PASSED