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.
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, seepytest_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
).
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.
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.
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.
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.
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.
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.
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.
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.
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