Skip to content

Commit b38ce9e

Browse files
committed
contest-hw: initial implementation
Signed-off-by: Jakub Kicinski <kuba@kernel.org>
1 parent 04f4ebe commit b38ce9e

21 files changed

Lines changed: 4336 additions & 8 deletions

contest/__init__.py

Whitespace-only changes.

contest/hw/README.rst

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,22 @@ Config
216216

217217
- reservation timeout, seconds
218218

219+
CLI
220+
---
221+
222+
The ``nipa-mctrl`` CLI (``/usr/local/bin/nipa-mctrl`` on ctrl) provides
223+
command-line access to the machine_control API::
224+
225+
nipa-mctrl machines # list machines and health state
226+
nipa-mctrl nics # list NICs
227+
nipa-mctrl sol --machine-id 1 # view SOL logs
228+
nipa-mctrl reserve --machine-ids 1,2 # reserve machines
229+
nipa-mctrl close --reservation-id 5 # release a reservation
230+
nipa-mctrl power-cycle --machine-id 1 # power cycle via BMC
231+
232+
Add ``--json`` for machine-parseable output. Defaults to
233+
``http://localhost:5050``; override with ``--url`` or ``MC_URL`` env var.
234+
219235
In-memory state
220236
---------------
221237

@@ -256,14 +272,12 @@ The service discovers all machines using the ``machine_info`` table at startup.
256272
SOL collection
257273
~~~~~~~~~~~~~~
258274

259-
Service assumes BMC of the machines is already configured to send SOL
260-
logs to the correct place. The service uses ipmitool call to
261-
enable the SOL output at startup (and disable it at shutdown).
262-
263-
The service maintains a UDP socket to receive the logs.
264-
The BMC ipaddr from ``machine_info_sec`` is used to identify the sending
265-
machine. The service inserts the logs into the correct table
266-
and does line chunking if necessary.
275+
At startup the service spawns a persistent ``ipmitool sol activate``
276+
session for each machine (using BMC credentials from ``machine_info_sec``).
277+
Each session runs in its own thread, reading stdout and inserting lines
278+
into the ``sol`` table. If a session drops it is automatically
279+
reconnected after a short delay. Stale sessions are deactivated before
280+
each new connection attempt.
267281

268282
Managing reservations
269283
~~~~~~~~~~~~~~~~~~~~~

contest/hw/hw_worker.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: GPL-2.0
3+
4+
"""NIPA HW worker — one-shot on-boot test runner."""
5+
6+
import json
7+
import os
8+
import subprocess
9+
10+
from lib.runner import find_newest_unseen, mark_all_seen, run_tests
11+
12+
13+
TESTS_DIR = '/srv/hw-worker/tests'
14+
RESULTS_DIR = '/srv/hw-worker/results'
15+
16+
# kselftest net.config keys (see drivers/net/README.rst)
17+
_NET_CONFIG_KEYS = ['NETIF', 'LOCAL_V4', 'LOCAL_V6', 'REMOTE_V4', 'REMOTE_V6',
18+
'LOCAL_PREFIX_V6', 'REMOTE_TYPE', 'REMOTE_ARGS']
19+
20+
21+
def _parse_env_file(path):
22+
"""Parse a simple KEY=VALUE env file."""
23+
env = {}
24+
if not os.path.exists(path):
25+
return env
26+
with open(path, encoding='utf-8') as fp:
27+
for line in fp:
28+
line = line.strip()
29+
if not line or line.startswith('#'):
30+
continue
31+
key, sep, val = line.partition('=')
32+
if sep:
33+
env[key.strip()] = val.strip()
34+
return env
35+
36+
37+
def _ensure_link_up(ifname):
38+
"""Bring a network interface up if not already."""
39+
subprocess.run(['ip', 'link', 'set', ifname, 'up'], check=True)
40+
41+
42+
def _ensure_addr(ifname, addr):
43+
"""Add an IP address to an interface if not already present."""
44+
bare_addr = addr.split('/')[0]
45+
ret = subprocess.run(['ip', 'addr', 'show', 'dev', ifname],
46+
capture_output=True, check=False)
47+
if bare_addr in ret.stdout.decode():
48+
return
49+
if '/' not in addr:
50+
addr += '/64' if ':' in addr else '/24'
51+
subprocess.run(['ip', 'addr', 'add', addr, 'dev', ifname], check=True)
52+
53+
54+
def setup_test_interfaces(test_dir):
55+
"""Configure test NICs and write net.config from nic-test.env.
56+
57+
The hwksft orchestrator deploys nic-test.env with interface names,
58+
IP addresses, and remote connectivity info. This function:
59+
1. Brings up the DUT and peer interfaces
60+
2. Adds IP addresses if not already configured
61+
3. Writes drivers/net/net.config for the kselftest framework
62+
"""
63+
env = _parse_env_file(os.path.join(test_dir, 'nic-test.env'))
64+
if not env:
65+
return
66+
67+
# Configure DUT interface
68+
netif = env.get('NETIF')
69+
if netif:
70+
_ensure_link_up(netif)
71+
if env.get('LOCAL_V4'):
72+
_ensure_addr(netif, env['LOCAL_V4'])
73+
if env.get('LOCAL_V6'):
74+
_ensure_addr(netif, env['LOCAL_V6'])
75+
76+
# Configure peer interface (for loopback / same-machine peers)
77+
remote_ifname = env.get('REMOTE_IFNAME')
78+
if remote_ifname:
79+
_ensure_link_up(remote_ifname)
80+
if env.get('REMOTE_V4'):
81+
_ensure_addr(remote_ifname, env['REMOTE_V4'])
82+
if env.get('REMOTE_V6'):
83+
_ensure_addr(remote_ifname, env['REMOTE_V6'])
84+
85+
# Write net.config for the kselftest framework
86+
config_lines = []
87+
for key in _NET_CONFIG_KEYS:
88+
if env.get(key):
89+
config_lines.append(f'{key}={env[key]}')
90+
91+
if config_lines:
92+
config_content = '\n'.join(config_lines) + '\n'
93+
for subdir in ['drivers/net', 'drivers/net/hw']:
94+
config_dir = os.path.join(test_dir, subdir)
95+
if os.path.isdir(config_dir):
96+
path = os.path.join(config_dir, 'net.config')
97+
with open(path, 'w', encoding='utf-8') as fp:
98+
fp.write(config_content)
99+
print(f"Wrote {path}")
100+
101+
102+
def main():
103+
"""Find pending tests, run them, and write results."""
104+
tests_dir = TESTS_DIR
105+
results_base = RESULTS_DIR
106+
107+
test_dir = find_newest_unseen(tests_dir)
108+
if test_dir is None:
109+
print("No outstanding tests found")
110+
return
111+
112+
# Verify we booted into the expected test kernel by comparing
113+
# the deployed kernel version against the running kernel.
114+
kver_path = os.path.join(test_dir, '.kernel-version')
115+
if not os.path.exists(kver_path):
116+
print("No kernel version file, skipping")
117+
return
118+
with open(kver_path, encoding='utf-8') as fp:
119+
expected = fp.read().strip()
120+
121+
actual = os.uname().release
122+
# The kernel version includes the git hash and instance name
123+
# (via CONFIG_LOCALVERSION), so accidental prefix collisions
124+
# (e.g. "6.1" matching "6.12.0") cannot happen in practice.
125+
# The '-' separator check is an extra safety measure.
126+
if actual != expected and not actual.startswith(expected + '-'):
127+
print(f"Kernel mismatch: running {actual}, expected {expected}")
128+
return
129+
130+
mark_all_seen(tests_dir)
131+
132+
# Configure test interfaces and write net.config
133+
setup_test_interfaces(test_dir)
134+
135+
reservation_id = os.path.basename(test_dir)
136+
results_dir = os.path.join(results_base, reservation_id)
137+
os.makedirs(results_dir, exist_ok=True)
138+
139+
results = run_tests(test_dir, results_dir)
140+
141+
results_file = os.path.join(results_dir, 'results.json')
142+
fd = os.open(results_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
143+
with os.fdopen(fd, 'w') as fp:
144+
json.dump(results, fp)
145+
fp.flush()
146+
os.fsync(fp.fileno())
147+
148+
print(f"Completed {len(results)} tests, results in {results_dir}")
149+
150+
151+
if __name__ == '__main__':
152+
main()

contest/hw/hwksft.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: GPL-2.0
3+
4+
"""NIPA HW kselftest orchestrator service."""
5+
6+
import datetime
7+
import os
8+
import subprocess
9+
import sys
10+
import time
11+
12+
# Add the project root to path for cross-package imports
13+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
14+
15+
# pylint: disable=wrong-import-position,wrong-import-order
16+
# Imports below require sys.path manipulation for cross-package access.
17+
18+
from core import NipaLifetime # noqa: E402 # pylint: disable=import-error
19+
20+
from contest.remote.lib.cbarg import CbArg # noqa: E402
21+
from contest.remote.lib.fetcher import Fetcher # noqa: E402
22+
23+
from lib.mc_client import MCClient, resolve_machines, resolve_nic_id # noqa: E402
24+
from lib.deployer import (build_kernel, build_ksft, deploy_artifacts, # noqa: E402
25+
kexec_machine, wait_for_results, fetch_results)
26+
27+
# Config:
28+
#
29+
# [executor]
30+
# name=hwksft-nic0
31+
# group=selftests-hw
32+
# init=force / continue / next
33+
# [remote]
34+
# branches=https://url-to-branches-manifest
35+
# [local]
36+
# base_path=/common/path
37+
# json_path=base-relative/path/to/json
38+
# results_path=base-relative/path/to/raw/outputs
39+
# tree_path=/root-path/to/kernel/git
40+
# patches_path=/root-path/to/patches/dir
41+
# [www]
42+
# url=https://url-to-reach-base-path
43+
# [hw]
44+
# nic_vendor=Intel
45+
# nic_model=E810-C
46+
# machine_control_url=http://control-node:5050
47+
# reservation_retry_time=60
48+
# max_kexec_boot_timeout=300
49+
# max_test_time=3600
50+
# crash_wait_time=120
51+
# sol_poll_interval=15
52+
# [build]
53+
# extra_kconfig=/path/to/nic-driver.config
54+
# [ksft]
55+
# target=net
56+
57+
58+
def test(binfo, rinfo, cbarg): # pylint: disable=unused-argument
59+
"""Fetcher callback: build, deploy, run, and collect HW test results."""
60+
print("Run at", datetime.datetime.now())
61+
cbarg.refresh_config()
62+
config = cbarg.config
63+
64+
results_path = os.path.join(config.get('local', 'base_path'),
65+
config.get('local', 'results_path'),
66+
rinfo['run-cookie'])
67+
os.makedirs(results_path, exist_ok=True)
68+
69+
link = config.get('www', 'url') + '/' + \
70+
config.get('local', 'results_path') + '/' + \
71+
rinfo['run-cookie']
72+
rinfo['link'] = link
73+
grp_name = config.get('executor', 'group', fallback='selftests-hw')
74+
75+
tree_path = config.get('local', 'tree_path')
76+
mc_url = config.get('hw', 'machine_control_url')
77+
nic_vendor = config.get('hw', 'nic_vendor')
78+
nic_model = config.get('hw', 'nic_model')
79+
mc = MCClient(mc_url)
80+
81+
# 1. Build kernel + ksft
82+
try:
83+
kernel_version = build_kernel(config, tree_path)
84+
build_ksft(config, tree_path)
85+
except (subprocess.CalledProcessError, OSError) as e:
86+
print(f"Build failed: {e}")
87+
return [{
88+
'test': 'build',
89+
'group': grp_name,
90+
'result': 'fail',
91+
'link': link,
92+
}]
93+
94+
# 2. Resolve machines for NIC
95+
all_nics = mc.get_nic_info()
96+
nic_id = resolve_nic_id(all_nics, nic_vendor, nic_model)
97+
machine_ids, nic = resolve_machines(all_nics, nic_id)
98+
99+
# Build nic_info dict with peer info for deployment
100+
nic_deploy_info = {
101+
'ifname': nic.get('ifname', ''),
102+
'ip4addr': nic.get('ip4addr', ''),
103+
'ip6addr': nic.get('ip6addr', ''),
104+
}
105+
if nic.get('peer_id'):
106+
for n in all_nics:
107+
if n['id'] == nic['peer_id']:
108+
nic_deploy_info['peer'] = {
109+
'ifname': n.get('ifname', ''),
110+
'ip4addr': n.get('ip4addr', ''),
111+
'ip6addr': n.get('ip6addr', ''),
112+
}
113+
break
114+
115+
# 3. Get machine IPs for SSH/SCP
116+
all_machines = mc.get_machine_info()
117+
machine_ip_map = {m['id']: m['mgmt_ipaddr'] for m in all_machines}
118+
machine_ips = [machine_ip_map[mid] for mid in machine_ids]
119+
120+
# Record peer machine IP so deployer can set REMOTE_ARGS
121+
if nic.get('peer_id'):
122+
for n in all_nics:
123+
if n['id'] == nic['peer_id']:
124+
nic_deploy_info['peer_machine_ip'] = machine_ip_map.get(
125+
n['machine_id'], machine_ips[0])
126+
break
127+
128+
# 4. Reserve machines (retry loop with backoff)
129+
max_retries = config.getint('hw', 'max_reservation_retries', fallback=30)
130+
retry_time = config.getint('hw', 'reservation_retry_time', fallback=60)
131+
reservation_id = None
132+
for attempt in range(max_retries):
133+
result = mc.reserve(machine_ids)
134+
if 'reservation_id' in result:
135+
reservation_id = result['reservation_id']
136+
break
137+
wait = min(retry_time * (1.5 ** attempt), 300)
138+
print(f"Reserve failed ({result.get('error', '?')}), "
139+
f"retry {attempt+1}/{max_retries} in {wait:.0f}s")
140+
time.sleep(wait)
141+
else:
142+
raise RuntimeError(f"Failed to reserve machines after {max_retries} attempts")
143+
144+
try:
145+
# 5. Deploy artifacts via SCP
146+
deploy_artifacts(config, machine_ips, reservation_id, nic_deploy_info,
147+
tree_path, kernel_version)
148+
149+
# 6. kexec into new kernel
150+
kexec_machine(config, machine_ips, reservation_id)
151+
152+
# 7. Wait for hw-worker with crash monitoring
153+
wait_for_results(config, mc, reservation_id, machine_ids, machine_ips)
154+
155+
# 8. Copy back results
156+
cases = fetch_results(config, machine_ips, reservation_id, rinfo)
157+
finally:
158+
# 9. Release reservation
159+
try:
160+
mc.reservation_close(reservation_id)
161+
except Exception as e:
162+
print(f"Warning: failed to close reservation {reservation_id}: {e}")
163+
164+
print("Done at", datetime.datetime.now())
165+
return cases
166+
167+
168+
def main():
169+
"""Entry point: set up Fetcher poll loop."""
170+
cfg_paths = ['hw.config', 'hwksft.config']
171+
if len(sys.argv) > 1:
172+
cfg_paths += sys.argv[1:]
173+
174+
cbarg = CbArg(cfg_paths)
175+
config = cbarg.config
176+
177+
base_dir = config.get('local', 'base_path')
178+
179+
life = NipaLifetime(config)
180+
181+
f = Fetcher(test, cbarg,
182+
name=config.get('executor', 'name'),
183+
branches_url=config.get('remote', 'branches'),
184+
results_path=os.path.join(base_dir, config.get('local', 'json_path')),
185+
url_path=config.get('www', 'url') + '/' + config.get('local', 'json_path'),
186+
tree_path=config.get('local', 'tree_path'),
187+
patches_path=config.get('local', 'patches_path', fallback=None),
188+
life=life,
189+
first_run=config.get('executor', 'init', fallback="continue"))
190+
f.run()
191+
life.exit()
192+
193+
194+
if __name__ == '__main__':
195+
main()

contest/hw/lib/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# SPDX-License-Identifier: GPL-2.0

0 commit comments

Comments
 (0)