Skip to content

Commit 1e4900d

Browse files
committed
Add saf.utils.salt.MasterClient and saf.utils.salt.MinionClient
Fixes #70 Signed-off-by: Pedro Algarvio <[email protected]>
1 parent 89081d9 commit 1e4900d

File tree

7 files changed

+174
-0
lines changed

7 files changed

+174
-0
lines changed

changelog/70.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `saf.utils.salt.MasterClient` and `saf.utils.salt.MinionClient` which are asyncio cooperative

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ format = "grouped"
120120
"src/saf/saltext/**/*.py" = [
121121
"N807", # Function name should not start and end with `__`
122122
]
123+
"src/saf/utils/salt.py" = [
124+
"ANN101", # Missing type annotation for `self` in method
125+
"PLR0913", # Too many arguments to function call (10 > 5)
126+
"FBT001", # Boolean positional arg in function definition
127+
"FBT002", # Boolean default value in function definition
128+
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs`
129+
"RET504", # Unnecessary variable assignment before `return` statement
130+
]
123131
"setup.py" = [
124132
"D",
125133
]

src/saf/utils/salt.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright 2023 VMware, Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
"""
5+
AsyncIO implementations of some of Salt's classes.
6+
"""
7+
from __future__ import annotations
8+
9+
import asyncio
10+
from functools import partial
11+
from typing import Any
12+
13+
import salt.client
14+
import salt.minion
15+
16+
17+
class MasterClient:
18+
"""
19+
Naive AsyncIO wrapper around Salt's LocalClient.
20+
"""
21+
22+
def __init__(self, master_opts: dict[str, Any]) -> None:
23+
self._opts = master_opts
24+
if self._opts["__role"] != "master":
25+
msg = "The options dictionary passed to MasterClient does not look like salt-master options."
26+
raise RuntimeError(msg)
27+
self._client = salt.client.LocalClient(mopts=self._opts.copy())
28+
29+
async def cmd(
30+
self,
31+
tgt: str,
32+
fun: str,
33+
arg: tuple[Any, ...] | list[Any] = (),
34+
timeout: int | None = None,
35+
tgt_type: str = "glob",
36+
ret: str = "",
37+
jid: str = "",
38+
full_return: bool = False,
39+
kwarg: dict[str, Any] | None = None,
40+
**kwargs: Any,
41+
) -> str | dict[str, Any]:
42+
"""
43+
Run a salt command.
44+
"""
45+
loop = asyncio.get_running_loop()
46+
return await loop.run_in_executor(
47+
None,
48+
partial(
49+
self._client.cmd,
50+
tgt=tgt,
51+
fun=fun,
52+
arg=arg,
53+
timeout=timeout,
54+
tgt_type=tgt_type,
55+
ret=ret,
56+
jid=jid,
57+
full_return=full_return,
58+
kwarg=kwarg,
59+
**kwargs,
60+
),
61+
)
62+
63+
64+
class MinionClient:
65+
"""
66+
Naive AsyncIO wrapper around Salt's minion execution modules.
67+
"""
68+
69+
def __init__(self, minion_opts: dict[str, Any]) -> None:
70+
self._opts = minion_opts.copy()
71+
self._opts["file_client"] = "local"
72+
self._client = salt.minion.SMinion(self._opts)
73+
74+
async def cmd(self, func: str, *args: Any, **kwargs: Any) -> Any:
75+
"""
76+
Run a salt command.
77+
"""
78+
if func not in self._client.functions:
79+
msg = f"The function {func!r} was not found, or could not be loaded."
80+
raise RuntimeError(msg)
81+
loop = asyncio.get_running_loop()
82+
return await loop.run_in_executor(
83+
None, partial(self._client.functions[func], *args, **kwargs)
84+
)
File renamed without changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Copyright 2023 VMware, Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
from __future__ import annotations
5+
6+
import pytest
7+
from saltfactories.daemons.master import SaltMaster
8+
from saltfactories.daemons.minion import SaltMinion
9+
from saltfactories.utils import random_string
10+
11+
from saf.utils.salt import MasterClient
12+
from saf.utils.salt import MinionClient
13+
14+
15+
@pytest.fixture(scope="module")
16+
def minion(master: SaltMaster) -> SaltMinion:
17+
factory = master.salt_minion_daemon(random_string("minion-"))
18+
with factory.started():
19+
yield factory
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_master_client(master, minion):
24+
client = MasterClient(master.config)
25+
ret = await client.cmd(minion.id, "test.ping")
26+
assert minion.id in ret
27+
assert ret[minion.id] is True
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_minion_client_from_master(master):
32+
client = MinionClient(master.config)
33+
ret = await client.cmd("test.ping")
34+
assert ret is True
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_cmd_from_minion(minion):
39+
client = MinionClient(minion.config)
40+
ret = await client.cmd("test.ping")
41+
assert ret is True
File renamed without changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright 2023 VMware, Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
from __future__ import annotations
5+
6+
import pytest
7+
import salt.config
8+
9+
from saf.utils.salt import MasterClient
10+
from saf.utils.salt import MinionClient
11+
12+
13+
@pytest.fixture
14+
def minion_opts(tmp_path):
15+
"""
16+
Default minion configuration with relative temporary paths to not require root permissions.
17+
"""
18+
root_dir = tmp_path / "minion"
19+
opts = salt.config.DEFAULT_MINION_OPTS.copy()
20+
opts["__role"] = "minion"
21+
opts["root_dir"] = str(root_dir)
22+
for name in ("cachedir", "pki_dir", "sock_dir", "conf_dir"):
23+
dirpath = root_dir / name
24+
dirpath.mkdir(parents=True)
25+
opts[name] = str(dirpath)
26+
opts["log_file"] = "logs/minion.log"
27+
return opts
28+
29+
30+
def test_master_client_runtime_error_on_wrong_role():
31+
opts = {"__role": "minion"}
32+
with pytest.raises(RuntimeError):
33+
MasterClient(opts)
34+
35+
36+
@pytest.mark.asyncio
37+
async def test_minin_client_missing_func(minion_opts):
38+
client = MinionClient(minion_opts)
39+
with pytest.raises(RuntimeError):
40+
await client.cmd("this_func.does_not_exist")

0 commit comments

Comments
 (0)