Skip to content

Commit 4a2aafe

Browse files
committed
fiotest: add support for test execution context
Key-value dictionary can now be passed as environment to the test execution routine. This allows to write test scripts which accept external variables for making pass/fail decisions. Signed-off-by: Milosz Wasilewski <milosz.wasilewski@foundries.io>
1 parent e65a085 commit 4a2aafe

File tree

6 files changed

+190
-14
lines changed

6 files changed

+190
-14
lines changed

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ services:
1313
- ${SOTA_DIR-/var/sota}:/var/sota
1414
- ${TEST_SPEC-./test-spec.yml}:/test-spec.yml
1515
- ${FIOTEST_DIR-/var/lib/fiotest}:/var/lib/fiotest
16+
- ${ETC_DIR-/etc}:/var/etc
1617
# Uncomment for devices registered with softhsm
1718
# - /var/lib/softhsm/:/var/lib/softhsm/

fiotest/api.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import json
2+
import logging
23
import os
34
import requests
45
import sys
56
from typing import Optional
67

78
from fiotest.gateway_client import DeviceGatewayClient
89

10+
log = logging.getLogger()
11+
912

1013
def status(msg: str, prefix: str = "== "):
1114
"""Print a commonly formatted status message to stdout.
@@ -86,19 +89,39 @@ def complete_test(
8689

8790
@staticmethod
8891
def target_name(sota_dir: str) -> str:
89-
with open(os.path.join(sota_dir, "current-target")) as f:
90-
for line in f:
91-
if line.startswith("TARGET_NAME"):
92-
k, v = line.split("=")
93-
return v.replace('"', "").strip() # remove spaces and quotes
92+
try:
93+
with open(os.path.join(sota_dir, "current-target")) as f:
94+
for line in f:
95+
if line.startswith("TARGET_NAME"):
96+
k, v = line.split("=")
97+
return v.replace('"', "").strip() # remove spaces and quotes
98+
except FileNotFoundError:
99+
pass # ignore the error and exit
94100
sys.exit("Unable to find current target")
95101

96102
@staticmethod
97103
def test_url(sota_dir: str) -> str:
98-
with open(os.path.join(sota_dir, "sota.toml")) as f:
99-
for line in f:
100-
if line.startswith("server ="):
104+
try:
105+
with open(os.path.join(sota_dir, "sota.toml")) as f:
106+
for line in f:
107+
if line.startswith("server ="):
108+
k, v = line.split("=")
109+
v = v.replace('"', "").strip() # remove spaces and quotes
110+
return v + "/tests"
111+
except FileNotFoundError:
112+
pass # ignore the error and exit
113+
sys.exit("Unable to find server url")
114+
115+
@staticmethod
116+
def file_variables(sota_dir: str, file_name: str) -> dict:
117+
ret_dict = {}
118+
try:
119+
with open(os.path.join(sota_dir, file_name)) as f:
120+
for line in f:
101121
k, v = line.split("=")
102122
v = v.replace('"', "").strip() # remove spaces and quotes
103-
return v + "/tests"
104-
sys.exit("Unable to find server url")
123+
ret_dict.update({k: v})
124+
except FileNotFoundError:
125+
log.warning(f"File {file_name} not found in {sota_dir}")
126+
pass
127+
return ret_dict

fiotest/runner.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import logging
3-
from os import execv, unlink
3+
import requests
4+
import os
45
import subprocess
56
from threading import Thread
67
from time import sleep
@@ -40,7 +41,7 @@ def run(self):
4041
"Detectected rebooted sequence, continuing after sequence %d",
4142
completed,
4243
)
43-
unlink(self.reboot_state)
44+
os.unlink(self.reboot_state)
4445
self.api.complete_test(data["test_id"], {})
4546
except FileNotFoundError:
4647
pass # This is the "normal" case - no reboot has occurred
@@ -84,9 +85,34 @@ def _reboot(self, seq_idx: int, reboot: Reboot):
8485
with open(self.reboot_state, "w") as f:
8586
state = {"seq_idx": seq_idx + 1, "test_id": test_id}
8687
json.dump(state, f)
87-
execv(reboot.command[0], reboot.command)
88+
os.execv(reboot.command[0], reboot.command)
89+
90+
def _prepare_context(self, context: dict):
91+
return_dict = {}
92+
if "url" in context.keys():
93+
context_dict = {}
94+
if os.path.exists("/var/sota/current-target"):
95+
context_dict = API.file_variables("/var/sota/", "current-target")
96+
if os.path.exists("/var/etc/os-release"):
97+
context_dict.update(API.file_variables("/var/etc/", "os-release"))
98+
target_url = context["url"]
99+
try:
100+
target_url = target_url.format(**context_dict)
101+
except KeyError:
102+
# ignore any missing keys
103+
pass
104+
log.info(f"Retrieving context from {target_url}")
105+
env_response = requests.get(target_url)
106+
if env_response.status_code == 200:
107+
return_dict = env_response.json()
108+
else:
109+
return_dict = context
110+
return return_dict
88111

89112
def _run_test(self, test: Test):
113+
environment = os.environ.copy()
114+
if test.context:
115+
environment.update(self._prepare_context(test.context))
90116
host_ip = netifaces.gateways()["default"][netifaces.AF_INET][0]
91117
args = ["/usr/local/bin/fio-test-wrap", test.name]
92118
if test.on_host:
@@ -100,9 +126,15 @@ def _run_test(self, test: Test):
100126
"fio@" + host_ip,
101127
]
102128
)
129+
# pass environment to host
130+
# this only works if PermitUserEnvironment is enabled
131+
# on host
132+
with open("~/.ssh/environment", "w") as sshfile:
133+
for env_name, env_value in environment.items():
134+
sshfile.write(f"{env_name}={env_value}\n")
103135
args.extend(test.command)
104136
with open("/tmp/tmp.log", "wb") as f:
105-
p = subprocess.Popen(args, stderr=f, stdout=f)
137+
p = subprocess.Popen(args, stderr=f, stdout=f, env=environment)
106138
while p.poll() is None:
107139
if not self.running:
108140
log.info("Killing test")

fiotest/spec.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Test(BaseModel):
77
name: str
88
command: List[str]
99
on_host: bool = False
10+
context: Optional[dict]
1011

1112

1213
class Reboot(BaseModel):

fiotest/tests.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import unittest
2+
import yaml
3+
4+
from time import sleep
5+
from unittest.mock import patch, MagicMock, PropertyMock
6+
7+
from fiotest.main import Coordinator
8+
from fiotest.runner import SpecRunner
9+
from fiotest.spec import TestSpec
10+
11+
SIMPLE_TEST_SPEC = """
12+
sequence:
13+
- tests:
14+
- name: test1
15+
context:
16+
url: https://example.com/{LMP_FACTORY}/{CUSTOM_VERSION}/{LMP_MACHINE}
17+
command:
18+
- /bin/true
19+
on_host: true
20+
- name: test2
21+
command:
22+
- /bin/true
23+
"""
24+
25+
class TestMain(unittest.TestCase):
26+
def setUp(self):
27+
data = yaml.safe_load(SIMPLE_TEST_SPEC)
28+
self.testspec = TestSpec.parse_obj(data)
29+
30+
@patch("fiotest.host.sudo_execute", return_value=0)
31+
def test_coordinator_check_for_updates_pre(self, mock_sudo_execute):
32+
self.coordinator = Coordinator(self.testspec)
33+
self.assertEqual(False, self.coordinator.callbacks_enabled)
34+
self.assertEqual(True, self.coordinator.timer.is_alive())
35+
self.coordinator.on_check_for_updates_pre("foo")
36+
sleep(1) # it takes a moment for thread to complete
37+
self.assertEqual(True, self.coordinator.callbacks_enabled)
38+
self.assertEqual(False, self.coordinator.timer.is_alive())
39+
40+
@patch("fiotest.main.SpecRunner")
41+
@patch("fiotest.host.sudo_execute", return_value=0)
42+
def test_coordinator_on_install_post_ok(self, mock_sudo_execute, mock_specrunner):
43+
mock_runner = MagicMock()
44+
mock_specrunner.return_value = mock_runner
45+
type(mock_specrunner).reboot_state = PropertyMock(return_value="/foo/bar")
46+
47+
self.coordinator = Coordinator(self.testspec)
48+
self.coordinator.on_check_for_updates_pre("foo")
49+
self.coordinator.on_install_post("foo", "OK")
50+
mock_specrunner.assert_called_once_with(self.testspec)
51+
mock_runner.start.assert_called_once()
52+
53+
@patch("fiotest.main.SpecRunner")
54+
@patch("fiotest.host.sudo_execute", return_value=0)
55+
def test_coordinator_on_install_post_fail(self, mock_sudo_execute, mock_specrunner):
56+
mock_runner = MagicMock()
57+
mock_specrunner.return_value = mock_runner
58+
type(mock_specrunner).reboot_state = PropertyMock(return_value="/foo/bar")
59+
60+
self.coordinator = Coordinator(self.testspec)
61+
self.coordinator.on_check_for_updates_pre("foo")
62+
self.coordinator.on_install_post("foo", "FAIL")
63+
mock_specrunner.assert_not_called()
64+
mock_runner.start.assert_not_called()
65+
66+
@patch("fiotest.main.SpecRunner")
67+
@patch("fiotest.host.sudo_execute", return_value=0)
68+
def test_coordinator_on_install_pre_no_runner(self, mock_sudo_execute, mock_specrunner):
69+
mock_runner = MagicMock()
70+
mock_specrunner.return_value = mock_runner
71+
type(mock_specrunner).reboot_state = PropertyMock(return_value="/foo/bar")
72+
73+
self.coordinator = Coordinator(self.testspec)
74+
self.coordinator.on_check_for_updates_pre("foo")
75+
self.coordinator.on_install_pre("foo")
76+
mock_runner.stop.assert_not_called()
77+
78+
@patch("fiotest.main.SpecRunner")
79+
@patch("fiotest.host.sudo_execute", return_value=0)
80+
def test_coordinator_on_install_pre(self, mock_sudo_execute, mock_specrunner):
81+
mock_runner = MagicMock()
82+
mock_specrunner.return_value = mock_runner
83+
type(mock_specrunner).reboot_state = PropertyMock(return_value="/foo/bar")
84+
85+
self.coordinator = Coordinator(self.testspec)
86+
self.coordinator.on_check_for_updates_pre("foo")
87+
self.coordinator.on_install_post("foo", "OK")
88+
mock_specrunner.assert_called_once_with(self.testspec)
89+
mock_runner.start.assert_called_once()
90+
self.coordinator.on_install_pre("foo")
91+
mock_runner.stop.assert_called()
92+
93+
94+
class TestRunner(unittest.TestCase):
95+
def setUp(self):
96+
data = yaml.safe_load(SIMPLE_TEST_SPEC)
97+
self.testspec = TestSpec.parse_obj(data)
98+
99+
@patch("fiotest.api.API.test_url", return_value="https://example.com/{FOO}")
100+
@patch("fiotest.runner.API.file_variables", return_value={"LMP_FACTORY": "factory", "CUSTOM_VERSION": 123, "LMP_MACHINE": "foo"})
101+
@patch("fiotest.api.DeviceGatewayClient")
102+
@patch("requests.get")
103+
@patch("subprocess.Popen")
104+
@patch("os.path.exists", return_value=True)
105+
def test_run(self, mock_path_exists, mock_popen, mock_requests_get, mock_gateway_client, mock_file_variables, mock_test_url):
106+
mock_response = MagicMock()
107+
mock_response.status_code = 200
108+
mock_requests_get.return_value = mock_response
109+
specrunner = SpecRunner(self.testspec)
110+
specrunner.running = True # run synchronously for testing
111+
specrunner.run()
112+
mock_requests_get.assert_called_with("https://example.com/factory/123/foo")
113+
mock_popen.assert_called()
114+
115+
116+
if __name__ == '__main__':
117+
unittest.main()

test-spec.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
sequence:
22
- tests:
33
- name: block devices
4+
context:
5+
url: https://conductor.infra.foundries.io/api/context/{LMP_FACTORY}/{CUSTOM_VERSION}/{LMP_MACHINE}
46
command:
57
- /usr/bin/lsblk
68
on_host: true

0 commit comments

Comments
 (0)